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,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);
}
}
+4 -1
View File
@@ -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.");
+4
View File
@@ -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)
{
+35 -6
View File
@@ -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);