feat: implement multi-tenancy support across knowledge services and normalize TenantId to string type.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeAsync(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>> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, 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<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, 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 MediatR;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
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 record VerifyGroundednessCommand(string Answer, string Context, string TenantId) : IRequest<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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
return await _knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, request.TenantId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@ using NexusReader.Application.Abstractions.Messaging;
|
||||
namespace NexusReader.Application.Queries.Graph;
|
||||
|
||||
/// <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))
|
||||
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)
|
||||
return Result.Fail<GraphDataDto>(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
@@ -20,7 +21,9 @@ public class NexusUser : IdentityUser
|
||||
/// <summary>
|
||||
/// Unique identifier for the tenant (SaaS multi-tenancy support).
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string TenantId { get; set; } = "global";
|
||||
|
||||
/// <summary>
|
||||
/// Current subscription plan (e.g., "Free", "Pro", "Enterprise").
|
||||
|
||||
@@ -74,11 +74,13 @@ public static class DependencyInjection
|
||||
|
||||
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
||||
|
||||
services.AddMediatR(config =>
|
||||
{
|
||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
|
||||
});
|
||||
services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
|
||||
|
||||
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,
|
||||
CurrentPlan = "Enterprise",
|
||||
AITokenLimit = 1000000,
|
||||
TenantId = Guid.NewGuid()
|
||||
TenantId = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!");
|
||||
|
||||
@@ -43,27 +43,27 @@ public class KnowledgeService : IKnowledgeService
|
||||
_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))
|
||||
{
|
||||
@@ -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<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.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<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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty.");
|
||||
|
||||
@@ -49,6 +49,10 @@ public static class MauiProgram
|
||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
|
||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||
NexusReader.Application.DependencyInjection.Assembly
|
||||
));
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="groundedness-badge @GetStatusClass()" title="@_result?.Rationale">
|
||||
@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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@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;
|
||||
|
||||
@@ -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<KnowledgePacket?> RequestSummaryAndQuizAsync(string content)
|
||||
public async Task<KnowledgePacket?> 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;
|
||||
|
||||
@@ -14,22 +14,22 @@ public class WasmKnowledgeService : IKnowledgeService
|
||||
_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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -53,6 +53,25 @@ public class WasmKnowledgeService : IKnowledgeService
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<IAuthorizationHandler, TokenLimitHandler>();
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
@@ -114,6 +119,16 @@ builder.Services.Configure<IdentityOptions>(options =>
|
||||
|
||||
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
|
||||
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>()
|
||||
app.Run();
|
||||
|
||||
public record KnowledgeRequest(string Text);
|
||||
public record GroundednessRequest(string Answer, string Context);
|
||||
|
||||
Reference in New Issue
Block a user