feat: implement multi-tenancy support across knowledge services and normalize TenantId to string type.

This commit is contained in:
2026-05-03 17:52:12 +02:00
parent eac0e9057e
commit e21c24b66d
16 changed files with 334 additions and 94 deletions
+5
View File
@@ -34,4 +34,9 @@ version: 1.0
1. **Verification-Led:** Plan and define tests/verification steps *before* writing feature code. 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. 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). 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.
+156
View File
@@ -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<EpubContent>(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<SubscriptionSettings>` 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<KnowledgeCoordinator>` 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** |
@@ -5,10 +5,13 @@ namespace NexusReader.Application.Abstractions.Services;
public interface IKnowledgeService public interface IKnowledgeService
{ {
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default); Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default);
Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default); Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default);
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default); Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
} }
public record GroundednessResult(float Score, string Rationale, bool IsGrounded);
@@ -1,51 +1,22 @@
using FluentResults; using FluentResults;
using MediatR; using MediatR;
using Microsoft.Extensions.AI; using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Commands.AI; namespace NexusReader.Application.Commands.AI;
public record VerifyGroundednessCommand(string Answer, string Context) : IRequest<Result<GroundednessResult>>; public record VerifyGroundednessCommand(string Answer, string Context, string TenantId) : IRequest<Result<GroundednessResult>>;
public record GroundednessResult(float Score, string Rationale, bool IsGrounded);
public class VerifyGroundednessCommandHandler : IRequestHandler<VerifyGroundednessCommand, Result<GroundednessResult>> public class VerifyGroundednessCommandHandler : IRequestHandler<VerifyGroundednessCommand, Result<GroundednessResult>>
{ {
private readonly IChatClient _chatClient; private readonly IKnowledgeService _knowledgeService;
public VerifyGroundednessCommandHandler(IChatClient chatClient) public VerifyGroundednessCommandHandler(IKnowledgeService knowledgeService)
{ {
_chatClient = chatClient; _knowledgeService = knowledgeService;
} }
public async Task<Result<GroundednessResult>> Handle(VerifyGroundednessCommand request, CancellationToken cancellationToken) public async Task<Result<GroundednessResult>> Handle(VerifyGroundednessCommand request, CancellationToken cancellationToken)
{ {
var systemPrompt = @" return await _knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, request.TenantId, cancellationToken);
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));
}
} }
} }
@@ -9,11 +9,8 @@ public static class DependencyInjection
{ {
services.AddMapsterConfiguration(); services.AddMapsterConfiguration();
services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
return services; return services;
} }
public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly;
} }
@@ -3,5 +3,6 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Graph; namespace NexusReader.Application.Queries.Graph;
/// <param name="Text">Chapter or page content to extract the graph from.</param> /// <param name="Text">Chapter or page content to extract the graph from.</param>
public record GetKnowledgeGraphQuery(string Text) : IQuery<GraphDataDto>; /// <param name="TenantId">Tenant scope for knowledge extraction and caching.</param>
public record GetKnowledgeGraphQuery(string Text, string TenantId) : IQuery<GraphDataDto>;
@@ -18,20 +18,13 @@ internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledge
if (string.IsNullOrWhiteSpace(request.Text)) if (string.IsNullOrWhiteSpace(request.Text))
return Result.Ok(new GraphDataDto()); return Result.Ok(new GraphDataDto());
var result = await _knowledgeService.GetGraphDataAsync(request.Text, cancellationToken); var result = await _knowledgeService.GetGraphDataAsync(request.Text, request.TenantId, cancellationToken);
if (result.IsFailed) if (result.IsFailed)
return Result.Fail<GraphDataDto>(result.Errors); return Result.Fail<GraphDataDto>(result.Errors);
var graph = result.Value.Graph; var graph = result.Value.Graph;
if (graph is null) return graph is null ? Result.Ok(new GraphDataDto()) : Result.Ok(graph);
return Result.Ok(new GraphDataDto());
if (graph is null)
return Result.Ok(new GraphDataDto());
return Result.Ok(graph);
} }
} }
+4 -1
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations;
namespace NexusReader.Domain.Entities; namespace NexusReader.Domain.Entities;
@@ -20,7 +21,9 @@ public class NexusUser : IdentityUser
/// <summary> /// <summary>
/// Unique identifier for the tenant (SaaS multi-tenancy support). /// Unique identifier for the tenant (SaaS multi-tenancy support).
/// </summary> /// </summary>
public Guid TenantId { get; set; } [Required]
[MaxLength(128)]
public string TenantId { get; set; } = "global";
/// <summary> /// <summary>
/// Current subscription plan (e.g., "Free", "Pro", "Enterprise"). /// Current subscription plan (e.g., "Free", "Pro", "Enterprise").
@@ -74,11 +74,13 @@ public static class DependencyInjection
services.AddScoped<IAuthorizationHandler, ProUserHandler>(); services.AddScoped<IAuthorizationHandler, ProUserHandler>();
services.AddMediatR(config => services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
return services; return services;
} }
public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly;
} }
public interface IInfrastructureMarker { }
internal class InfrastructureMarker : IInfrastructureMarker { }
@@ -44,7 +44,7 @@ public static class DbInitializer
EmailConfirmed = true, EmailConfirmed = true,
CurrentPlan = "Enterprise", CurrentPlan = "Enterprise",
AITokenLimit = 1000000, AITokenLimit = 1000000,
TenantId = Guid.NewGuid() TenantId = Guid.NewGuid().ToString()
}; };
var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!"); var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!");
@@ -43,27 +43,27 @@ public class KnowledgeService : IKnowledgeService
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
} }
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> 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<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> 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<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> 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<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> 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<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken) private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
@@ -84,7 +84,7 @@ public class KnowledgeService : IKnowledgeService
// 1. Check Cache // 1. Check Cache
var cached = await _dbContext.SemanticKnowledgeCache 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) if (cached != null)
{ {
@@ -146,7 +146,7 @@ public class KnowledgeService : IKnowledgeService
OriginalText = normalizedText, 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 TenantId = tenantId,
Vector = vector, Vector = vector,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -163,7 +163,7 @@ public class KnowledgeService : IKnowledgeService
// 5. Process KM-RAG Units and Links if present // 5. Process KM-RAG Units and Links if present
if (knowledgePacket.Units.Any()) if (knowledgePacket.Units.Any())
{ {
await ProcessKnowledgeUnitsAsync(knowledgePacket, "global", cancellationToken); await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, cancellationToken);
} }
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
@@ -191,7 +191,7 @@ public class KnowledgeService : IKnowledgeService
var unit = existing ?? new KnowledgeUnit { Id = unitId, TenantId = tenantId }; 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.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
unit.Content = unitDto.Content; unit.Content = unitDto.Content;
unit.SourceId = "extracted"; // Should be passed from context unit.SourceId = "extracted";
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata); unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
// Generate unit-specific embedding for granular retrieval // Generate unit-specific embedding for granular retrieval
@@ -217,6 +217,44 @@ public class KnowledgeService : IKnowledgeService
} }
} }
public async Task<Result<GroundednessResult>> 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<ChatMessage>
{
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<GroundednessResult>(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<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty."); if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty.");
+4
View File
@@ -50,6 +50,10 @@ public static class MauiProgram
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
NexusReader.Application.DependencyInjection.Assembly
));
return builder.Build(); return builder.Build();
} }
catch (Exception ex) catch (Exception ex)
@@ -1,7 +1,10 @@
@using MediatR @using MediatR
@using NexusReader.Application.Commands.AI @using NexusReader.Application.Commands.AI
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@using Microsoft.AspNetCore.Components.Authorization
@inject IMediator Mediator @inject IMediator Mediator
@inject AuthenticationStateProvider AuthProvider
<div class="groundedness-badge @GetStatusClass()" title="@_result?.Rationale"> <div class="groundedness-badge @GetStatusClass()" title="@_result?.Rationale">
@if (_isChecking) @if (_isChecking)
@@ -29,11 +32,24 @@
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.groundedness-badge.status-high { color: var(--nexus-neon); border-color: var(--nexus-neon); } .groundedness-badge.status-high {
.groundedness-badge.status-medium { color: #ffaa00; border-color: #ffaa00; } color: var(--nexus-neon);
.groundedness-badge.status-low { color: #ff4444; border-color: #ff4444; } 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;
}
</style> </style>
@code { @code {
@@ -56,7 +72,10 @@
_isChecking = true; _isChecking = true;
StateHasChanged(); 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) if (res.IsSuccess)
{ {
_result = res.Value; _result = res.Value;
@@ -38,7 +38,7 @@ public sealed class KnowledgeCoordinator : IDisposable
_interactionService.RequestHighlightBlock(nodeId); _interactionService.RequestHighlightBlock(nodeId);
} }
public async Task ProcessFullPageAsync(string fullContent) public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global")
{ {
if (string.IsNullOrWhiteSpace(fullContent)) return; if (string.IsNullOrWhiteSpace(fullContent)) return;
@@ -49,7 +49,7 @@ public sealed class KnowledgeCoordinator : IDisposable
try try
{ {
var result = await _knowledgeService.GetGraphDataAsync(fullContent); var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId);
if (result.IsSuccess) if (result.IsSuccess)
{ {
var packet = result.Value; var packet = result.Value;
@@ -73,12 +73,12 @@ public sealed class KnowledgeCoordinator : IDisposable
_graphService.SetActiveNode(blockId); _graphService.SetActiveNode(blockId);
} }
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content) public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{ {
_quizService.SetHydrating(true); _quizService.SetHydrating(true);
try try
{ {
var result = await _knowledgeService.GetSummaryAndQuizAsync(content); var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId);
if (result.IsSuccess) if (result.IsSuccess)
{ {
var packet = result.Value; var packet = result.Value;
@@ -14,22 +14,22 @@ public class WasmKnowledgeService : IKnowledgeService
_httpClient = httpClient; _httpClient = httpClient;
} }
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken); return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken); return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge/map", text, cancellationToken); return await CallKnowledgeApiAsync("/api/knowledge/map", text, cancellationToken);
} }
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default) public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default)
{ {
return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken); 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)); return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
} }
} }
public async Task<Result<GroundednessResult>> 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<GroundednessResult>(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<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken) private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken)
{ {
+35 -6
View File
@@ -59,6 +59,11 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
NexusReader.Application.DependencyInjection.Assembly,
NexusReader.Infrastructure.DependencyInjection.Assembly
));
// Authorization Policies // Authorization Policies
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>(); builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
builder.Services.AddAuthorizationBuilder() builder.Services.AddAuthorizationBuilder()
@@ -114,6 +119,16 @@ builder.Services.Configure<IdentityOptions>(options =>
var app = builder.Build(); var app = builder.Build();
// Startup Validation
using (var scope = app.Services.CreateScope())
{
var marker = scope.ServiceProvider.GetService<IInfrastructureMarker>();
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 // Ensure Database is initialized and seeded
using (var scope = app.Services.CreateScope()) 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"); 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); if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); 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); if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); 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); if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); 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) => knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
{ {
var result = await knowledgeService.ClearCacheAsync(); var result = await knowledgeService.ClearCacheAsync();
@@ -377,3 +405,4 @@ app.MapRazorComponents<App>()
app.Run(); app.Run();
public record KnowledgeRequest(string Text); public record KnowledgeRequest(string Text);
public record GroundednessRequest(string Answer, string Context);