+ Analyzing conceptual graphs and synthesizing response...
+
}
}
- else if (_hasSearched)
- {
-
-
-
No answers generated. Try adjusting your question.
-
- }
- else
- {
-
-
-
+
+
+
+
+
+
+
+
-
Start Interrogating Your Library
-
Ask complex questions across all your books. The system will search vectors, pull concept graph relations, and formulate a grounded answer with precise citations.
- }
+
+
+
+
+
+
@code {
private string _question = string.Empty;
private string _selectedBookId = string.Empty;
private bool _isLoading;
- private bool _hasSearched;
- private GroundedResponseDto? _response;
private List? _books;
+ private List _chatMessages = new();
+
+ public class ChatMessage
+ {
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Sender { get; set; } = string.Empty; // "User" or "AI"
+ public string Text { get; set; } = string.Empty;
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+ public List Segments { get; set; } = new();
+ public List Citations { get; set; } = new();
+ }
+
+ public class ResponseSegment
+ {
+ public string Text { get; set; } = string.Empty;
+ public bool IsCitation { get; set; }
+ public string CitationId { get; set; } = string.Empty;
+ }
protected override async Task OnInitializedAsync()
{
@@ -457,9 +592,18 @@
{
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
+ var userQuestion = _question;
+ _question = string.Empty; // Clear input field immediately
_isLoading = true;
- _hasSearched = true;
- _response = null;
+
+ // Add user query message
+ _chatMessages.Add(new ChatMessage
+ {
+ Sender = "User",
+ Text = userQuestion,
+ Segments = new List { new ResponseSegment { Text = userQuestion, IsCitation = false } }
+ });
+
StateHasChanged();
try
@@ -473,27 +617,38 @@
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
- var result = await KnowledgeService.AskQuestionAsync(_question, tenantId, ebookId);
+ var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
if (result.IsSuccess)
{
- _response = result.Value;
+ var response = result.Value;
+ _chatMessages.Add(new ChatMessage
+ {
+ Sender = "AI",
+ Text = response.Answer,
+ Segments = ParseSegments(response.Answer),
+ Citations = response.Citations
+ });
}
else
{
- _response = new GroundedResponseDto
+ var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
+ _chatMessages.Add(new ChatMessage
{
- Answer = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}",
- Citations = new List()
- };
+ Sender = "AI",
+ Text = errMsg,
+ Segments = new List { new ResponseSegment { Text = errMsg, IsCitation = false } }
+ });
}
}
catch (Exception ex)
{
- _response = new GroundedResponseDto
+ var errMsg = $"Network/API Error: {ex.Message}";
+ _chatMessages.Add(new ChatMessage
{
- Answer = $"Network/API Error: {ex.Message}",
- Citations = new List()
- };
+ Sender = "AI",
+ Text = errMsg,
+ Segments = new List { new ResponseSegment { Text = errMsg, IsCitation = false } }
+ });
}
finally
{
@@ -501,4 +656,77 @@
StateHasChanged();
}
}
+
+ private List ParseSegments(string text)
+ {
+ var segments = new List();
+ if (string.IsNullOrEmpty(text)) return segments;
+
+ // Matches [Source ID: some-id] OR raw GUIDs in brackets [e225e58f-7539-cd51-e0ab-82741ec7e65c]
+ var regex = new System.Text.RegularExpressions.Regex(
+ @"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ var matches = regex.Matches(text);
+
+ int lastIndex = 0;
+ foreach (System.Text.RegularExpressions.Match match in matches)
+ {
+ if (match.Index > lastIndex)
+ {
+ segments.Add(new ResponseSegment
+ {
+ Text = text.Substring(lastIndex, match.Index - lastIndex),
+ IsCitation = false
+ });
+ }
+
+ var citationId = match.Groups[1].Success
+ ? match.Groups[1].Value.Trim()
+ : match.Groups[2].Value.Trim();
+
+ segments.Add(new ResponseSegment
+ {
+ IsCitation = true,
+ CitationId = citationId
+ });
+
+ lastIndex = match.Index + match.Length;
+ }
+
+ if (lastIndex < text.Length)
+ {
+ segments.Add(new ResponseSegment
+ {
+ Text = text.Substring(lastIndex),
+ IsCitation = false
+ });
+ }
+
+ return segments;
+ }
+
+ private MarkupString RenderMarkdown(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
+
+ // 1. HTML Encode to prevent XSS
+ var html = System.Net.WebUtility.HtmlEncode(text);
+
+ // 2. Bold: **text** -> text
+ html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "$1");
+
+ // 3. Italic: *text* -> text
+ html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "$1");
+
+ // 4. Code blocks: ```language ... ``` ->
...
+ html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "
$1
");
+
+ // 5. Inline Code: `code` -> code
+ html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "$1");
+
+ // 6. Newlines: \n ->
+ html = html.Replace("\n", " ");
+
+ return new MarkupString(html);
+ }
}
diff --git a/src/NexusReader.UI.Shared/Services/ISyncService.cs b/src/NexusReader.UI.Shared/Services/ISyncService.cs
index adcec95..8fce9aa 100644
--- a/src/NexusReader.UI.Shared/Services/ISyncService.cs
+++ b/src/NexusReader.UI.Shared/Services/ISyncService.cs
@@ -7,5 +7,6 @@ public interface ISyncService
Task InitializeAsync();
Task UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle, int chapterIndex);
event Func OnProgressReceived;
+ event Func? OnIngestionProgressReceived;
Task DisposeAsync();
}
diff --git a/src/NexusReader.UI.Shared/Services/IdentityService.cs b/src/NexusReader.UI.Shared/Services/IdentityService.cs
index 441ea88..386dbe6 100644
--- a/src/NexusReader.UI.Shared/Services/IdentityService.cs
+++ b/src/NexusReader.UI.Shared/Services/IdentityService.cs
@@ -249,6 +249,25 @@ public class IdentityService : IIdentityService
}
}
+ public void ClearCache()
+ {
+ _cachedProfile = null;
+ if (OnStateInvalidated != null)
+ {
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await OnStateInvalidated.Invoke();
+ }
+ catch
+ {
+ // Ignore exceptions from event handlers
+ }
+ });
+ }
+ }
+
private class LoginResponse
{
public string TokenType { get; set; } = string.Empty;
diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
index 7121ba5..9489616 100644
--- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
+++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
@@ -17,6 +17,8 @@ public sealed partial class KnowledgeCoordinator : IDisposable
private readonly IReaderInteractionService _interactionService;
private readonly ILogger _logger;
+ public string CurrentFullPageContent { get; private set; } = string.Empty;
+
///
/// Raised when the knowledge graph has been updated with new data.
/// Subscribers must return a Task to enable proper async handling.
@@ -77,6 +79,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
{
if (string.IsNullOrWhiteSpace(fullContent)) return;
+ CurrentFullPageContent = fullContent;
LogGeneratingGraph(tenantId);
await _graphService.Clear();
@@ -94,11 +97,15 @@ public sealed partial class KnowledgeCoordinator : IDisposable
if (OnGraphUpdated != null)
await OnGraphUpdated.Invoke(packet.Graph);
await _platformService.VibrateSuccessAsync();
+ return;
}
}
+
+ await _graphService.SetLoading(false);
}
catch (Exception ex)
{
+ await _graphService.SetLoading(false);
LogGraphError(ex, tenantId);
}
}
@@ -144,6 +151,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task ClearAsync()
{
+ CurrentFullPageContent = string.Empty;
await _graphService.Clear();
await _quizService.SetQuiz(null, null);
}
diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs
index 16c986f..1494f2d 100644
--- a/src/NexusReader.UI.Shared/Services/SyncService.cs
+++ b/src/NexusReader.UI.Shared/Services/SyncService.cs
@@ -16,6 +16,7 @@ public class SyncService : ISyncService, IAsyncDisposable
private CancellationTokenSource? _debounceCts;
public event Func? OnProgressReceived;
+ public event Func? OnIngestionProgressReceived;
public SyncService(
HttpClient httpClient,
@@ -50,9 +51,20 @@ public class SyncService : ISyncService, IAsyncDisposable
_hubConnection.On("ProgressUpdated", async (pageId, timestamp) =>
{
// Note: In the future we might want to receive ebookId and progress here too
+ if (pageId == _lastSentPageId)
+ {
+ _logger.LogDebug("[Sync] Ignoring self progress update for page {PageId}.", pageId);
+ return;
+ }
+ _lastSentPageId = pageId; // Prevent echoing back duplicate progress updates
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
});
+ _hubConnection.On("IngestionProgress", async (message, progress) =>
+ {
+ if (OnIngestionProgressReceived != null) await OnIngestionProgressReceived(message, progress);
+ });
+
try
{
await _hubConnection.StartAsync();
@@ -71,6 +83,8 @@ public class SyncService : ISyncService, IAsyncDisposable
{
if (pageId == _lastSentPageId) return Result.Ok();
+ _lastSentPageId = pageId;
+
// Proper trailing-edge debounce
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
@@ -86,8 +100,7 @@ public class SyncService : ISyncService, IAsyncDisposable
if (_hubConnection?.State == HubConnectionState.Connected)
{
- await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token);
- _lastSentPageId = pageId;
+ await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex);
}
}
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js
index f83f487..7356915 100644
--- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js
+++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js
@@ -3,6 +3,110 @@ import * as d3 from 'https://esm.sh/d3@7';
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
+const getNodeType = d => {
+ if (d) {
+ if (d.type) {
+ const t = d.type.toLowerCase();
+ if (t === 'definition') return 'definition';
+ if (t === 'table') return 'table';
+ if (t === 'rule') return 'rule';
+ if (t === 'section') return 'section';
+ }
+ if (d.group) {
+ const g = d.group.toLowerCase();
+ if (g === 'definition') return 'definition';
+ if (g === 'table') return 'table';
+ if (g === 'rule') return 'rule';
+ if (g === 'section') return 'section';
+ }
+ }
+ return null;
+};
+
+const getNodeGroup = d => {
+ if (d && d.group) {
+ const g = d.group.toLowerCase();
+ if (g === 'bridge') return 'bridge';
+ if (g === 'current') return 'current';
+ if (g === 'concept') return 'concept';
+ }
+ return 'concept'; // fallback
+};
+
+const getCategoryStyle = d => {
+ const type = getNodeType(d);
+ const group = getNodeGroup(d);
+
+ // 1. Rule (red/coral)
+ if (type === 'rule') {
+ return {
+ color: '#ff4646',
+ fill: 'rgba(255, 70, 70, 0.1)',
+ opacity: 0.8,
+ glowKey: 'rule',
+ textColor: '#ff8b8b'
+ };
+ }
+ // 2. Definition (gold/amber)
+ if (type === 'definition') {
+ return {
+ color: '#ffb03a',
+ fill: 'rgba(255, 176, 58, 0.1)',
+ opacity: 0.8,
+ glowKey: 'definition',
+ textColor: '#ffd18c'
+ };
+ }
+ // 3. Table (purple/magenta)
+ if (type === 'table') {
+ return {
+ color: '#d946ef',
+ fill: 'rgba(217, 70, 239, 0.1)',
+ opacity: 0.8,
+ glowKey: 'table',
+ textColor: '#f5d0fe'
+ };
+ }
+ // 4. Section (blue/indigo)
+ if (type === 'section') {
+ return {
+ color: '#3b82f6',
+ fill: 'rgba(59, 130, 246, 0.1)',
+ opacity: 0.8,
+ glowKey: 'section',
+ textColor: '#93c5fd'
+ };
+ }
+ // 5. Bridge (cyan/comparison)
+ if (group === 'bridge') {
+ return {
+ color: '#06b6d4',
+ fill: 'rgba(6, 182, 212, 0.1)',
+ opacity: 0.7,
+ glowKey: 'bridge',
+ textColor: '#67e8f9'
+ };
+ }
+ // 6. Current (active/focus landmark - neon green)
+ if (group === 'current') {
+ return {
+ color: 'var(--nexus-neon)',
+ fill: 'rgba(0, 255, 153, 0.15)',
+ opacity: 0.9,
+ glowKey: 'current',
+ textColor: '#ffffff'
+ };
+ }
+ // 7. Concept / Default (subtle cool steel blue/teal)
+ return {
+ color: '#00d2c4',
+ fill: 'rgba(0, 210, 196, 0.05)',
+ opacity: 0.4,
+ glowKey: 'concept',
+ textColor: '#e0e0e0'
+ };
+};
+
let simulation;
let zoomBehavior;
let svgElement;
@@ -24,8 +128,10 @@ export function mount(containerId, data, dotNetHelper) {
.attr("height", "100%")
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
- // Radial gradient for Nebula effect
+ // Radial gradients for Nebula effects
const defs = svgElement.append("defs");
+
+ // Fallback radial gradient for legacy nebulaGlow
const radialGradient = defs.append("radialGradient")
.attr("id", "nebulaGlow")
.attr("cx", "50%")
@@ -34,6 +140,26 @@ export function mount(containerId, data, dotNetHelper) {
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
+ const colors = {
+ 'rule': '#ff4646',
+ 'definition': '#ffb03a',
+ 'table': '#d946ef',
+ 'section': '#3b82f6',
+ 'bridge': '#06b6d4',
+ 'current': 'var(--nexus-neon)',
+ 'concept': '#00d2c4'
+ };
+
+ Object.entries(colors).forEach(([key, color]) => {
+ const radGrad = defs.append("radialGradient")
+ .attr("id", `nebulaGlow-${key}`)
+ .attr("cx", "50%")
+ .attr("cy", "50%")
+ .attr("r", "50%");
+ radGrad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 1);
+ radGrad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0);
+ });
+
// Root Group for Zoom
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
@@ -135,21 +261,33 @@ export function updateData(data) {
}
});
+ // Sanitize links to filter out any references to non-existent nodes
+ const nodeIds = new Set(data.nodes.map(n => n.id));
+ const validLinks = (data.links || []).filter(l => {
+ const srcId = typeof l.source === 'object' ? l.source.id : l.source;
+ const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
+ return nodeIds.has(srcId) && nodeIds.has(tgtId);
+ });
+
// Update Links
link = rootGroup.select(".links-layer")
.selectAll("path")
- .data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
+ .data(validLinks, d => {
+ const srcId = typeof d.source === 'object' ? d.source.id : d.source;
+ const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
+ return srcId + "-" + tgtId + "-" + d.type;
+ })
.join(
enter => enter.append("path")
.attr("stroke", d => {
- if (d.relationType === 'Defines') return 'var(--nexus-accent)';
- if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)';
- if (d.relationType === 'Contains') return 'var(--nexus-neon)';
+ if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
+ if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)';
+ if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
return 'rgba(255,255,255,0.1)';
})
.attr("fill", "none")
- .attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
- .attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
+ .attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
+ .attr("stroke-dasharray", d => d.type === 'References' ? "5,5" : "0")
.style("opacity", 0)
.call(enter => enter.transition().duration(500).style("opacity", 1)),
update => update,
@@ -174,13 +312,8 @@ export function updateData(data) {
g.append("circle")
.attr("r", 30)
- .attr("fill", d => {
- if (d.type === 'Definition') return 'var(--nexus-accent)';
- if (d.type === 'Table') return 'var(--nexus-neon)';
- if (d.type === 'Rule') return '#ff4444';
- return "url(#nebulaGlow)";
- })
- .attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
+ .attr("fill", d => `url(#nebulaGlow-${getCategoryStyle(d).glowKey})`)
+ .attr("opacity", d => getCategoryStyle(d).opacity);
g.append("rect")
.attr("class", "node-pill")
@@ -189,23 +322,20 @@ export function updateData(data) {
.attr("width", d => getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
- .attr("fill", "rgba(20, 20, 20, 0.9)")
- .attr("stroke", d => {
- if (d.type === 'Definition') return 'var(--nexus-accent)';
- if (d.type === 'Rule') return '#ff4444';
- return "rgba(255, 255, 255, 0.1)";
- })
- .attr("stroke-width", 1);
+ .attr("fill", "rgba(20, 20, 20, 0.95)")
+ .attr("stroke", d => getCategoryStyle(d).color)
+ .attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
g.append("text")
.text(d => getDisplayLabel(d))
.attr("text-anchor", "middle")
.attr("y", 5)
- .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
- .attr("font-size", "0.8rem");
+ .attr("fill", d => getCategoryStyle(d).textColor)
+ .attr("font-size", "0.8rem")
+ .attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
g.append("title")
- .text(d => d.label);
+ .text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
g.transition().duration(500).style("opacity", 1);
@@ -216,7 +346,7 @@ export function updateData(data) {
);
simulation.nodes(data.nodes);
- simulation.force("link").links(data.links);
+ simulation.force("link").links(validLinks);
simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
@@ -398,6 +528,15 @@ export function clear() {
}
simulation.nodes([]);
}
+
+ // Reset selections
+ link = null;
+ node = null;
+
+ // Reset D3 zoom transform to clean identity state
+ if (svgElement && zoomBehavior) {
+ svgElement.call(zoomBehavior.transform, d3.zoomIdentity);
+ }
} catch (e) {
console.warn("Failed to clear force simulation safely:", e);
}
diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs
index dcc3569..bf431b7 100644
--- a/src/NexusReader.Web.Client/Program.cs
+++ b/src/NexusReader.Web.Client/Program.cs
@@ -51,6 +51,7 @@ builder.Services.AddSingleton>>(new
builder.Services.AddSingleton(new ThrowingBookStorageService());
builder.Services.AddSingleton(new ThrowingEbookRepository());
builder.Services.AddSingleton(new ThrowingSyncBroadcaster());
+builder.Services.AddSingleton(new ThrowingEpubExtractor());
builder.Services.AddApplication();
builder.Services.AddScoped();
@@ -99,3 +100,9 @@ public class ThrowingSyncBroadcaster : ISyncBroadcaster
public Task BroadcastIngestionProgressAsync(string userId, string message, double progress, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Real-time broadcasting can only be performed by the server.");
}
+
+public class ThrowingEpubExtractor : IEpubExtractor
+{
+ public Task>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
+}
diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs
index 8123f92..9e10a59 100644
--- a/src/NexusReader.Web/Program.cs
+++ b/src/NexusReader.Web/Program.cs
@@ -106,7 +106,8 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddIdentityApiEndpoints()
.AddRoles()
- .AddEntityFrameworkStores();
+ .AddEntityFrameworkStores()
+ .AddClaimsPrincipalFactory();
builder.Services.ConfigureApplicationCookie(options =>
{
@@ -194,6 +195,7 @@ using (var scope = app.Services.CreateScope())
await dbContext.Database.MigrateAsync();
await DbInitializer.SeedAsync(services);
+ await TriggerBackgroundProcessingForUnindexedBooksAsync(services);
if (logger.IsEnabled(LogLevel.Information))
{
@@ -337,13 +339,16 @@ app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request,
? Convert.FromBase64String(request.CoverImageBase64)
: null;
+ var tenantId = user.FindFirst("TenantId")?.Value ?? "global";
+
var command = new IngestEbookCommand(
request.Title,
request.AuthorName,
coverData,
epubData,
request.Description,
- userId
+ userId,
+ tenantId
);
var result = await mediator.Send(command);
@@ -563,6 +568,50 @@ app.MapRazorComponents()
app.Run();
+async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider services)
+{
+ var logger = services.GetRequiredService>();
+ try
+ {
+ var dbContextFactory = services.GetRequiredService>();
+ using var dbContext = await dbContextFactory.CreateDbContextAsync();
+
+ var unindexedEbooks = await dbContext.Ebooks
+ .Where(e => !e.IsReadyForReading)
+ .ToListAsync();
+
+ if (unindexedEbooks.Any())
+ {
+ logger.LogInformation("[Startup] Found {Count} un-indexed ebooks. Triggering background processing...", unindexedEbooks.Count);
+
+ foreach (var ebook in unindexedEbooks)
+ {
+ logger.LogInformation("[Startup] Queuing background processing for ebook: '{Title}' ({Id})", ebook.Title, ebook.Id);
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ using var scope = services.CreateScope();
+ var scopedMediator = scope.ServiceProvider.GetRequiredService();
+ await scopedMediator.Send(new ProcessEbookCommand(ebook.Id, ebook.UserId, ebook.TenantId));
+ }
+ catch (Exception ex)
+ {
+ using var scope = services.CreateScope();
+ var scopedLogger = scope.ServiceProvider.GetRequiredService>();
+ scopedLogger.LogError(ex, "Failed to run background processing for ebook {EbookId} on startup", ebook.Id);
+ }
+ });
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking or triggering background processing for unindexed books on startup.");
+ }
+}
+
public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context);
public record SemanticSearchRequest(string QueryText, int Limit = 5);
diff --git a/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs b/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs
new file mode 100644
index 0000000..c06ec78
--- /dev/null
+++ b/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs
@@ -0,0 +1,28 @@
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
+using NexusReader.Domain.Entities;
+
+namespace NexusReader.Web.Services;
+
+public class CustomUserClaimsPrincipalFactory : UserClaimsPrincipalFactory
+{
+ public CustomUserClaimsPrincipalFactory(
+ UserManager userManager,
+ RoleManager roleManager,
+ IOptions optionsAccessor)
+ : base(userManager, roleManager, optionsAccessor)
+ {
+ }
+
+ protected override async Task GenerateClaimsAsync(NexusUser user)
+ {
+ var identity = await base.GenerateClaimsAsync(user);
+ if (!string.IsNullOrEmpty(user.TenantId))
+ {
+ identity.AddClaim(new Claim("TenantId", user.TenantId));
+ }
+ return identity;
+ }
+}
diff --git a/src/NexusReader.Web/Services/ServerIdentityService.cs b/src/NexusReader.Web/Services/ServerIdentityService.cs
index 164aaac..2a1aaff 100644
--- a/src/NexusReader.Web/Services/ServerIdentityService.cs
+++ b/src/NexusReader.Web/Services/ServerIdentityService.cs
@@ -118,4 +118,22 @@ public class ServerIdentityService : IIdentityService
return Result.Ok(result.Value);
}
+
+ public void ClearCache()
+ {
+ if (OnStateInvalidated != null)
+ {
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await OnStateInvalidated.Invoke();
+ }
+ catch
+ {
+ // Ignore
+ }
+ });
+ }
+ }
}
diff --git a/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs b/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs
new file mode 100644
index 0000000..4c9902a
--- /dev/null
+++ b/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Moq;
+using NexusReader.Application.Commands.Quiz;
+using NexusReader.Data.Persistence;
+using NexusReader.Domain.Entities;
+using Xunit;
+
+namespace NexusReader.Application.Tests.Commands;
+
+public class SubmitQuizResultCommandHandlerTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions _contextOptions;
+ private readonly Mock> _dbContextFactoryMock;
+
+ public SubmitQuizResultCommandHandlerTests()
+ {
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ _contextOptions = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = new AppDbContext(_contextOptions);
+ context.Database.EnsureCreated();
+
+ _dbContextFactoryMock = new Mock>();
+ _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny()))
+ .ReturnsAsync(() => new AppDbContext(_contextOptions));
+ }
+
+ [Fact]
+ public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase()
+ {
+ // Arrange
+ using (var context = new AppDbContext(_contextOptions))
+ {
+ var user = new NexusUser
+ {
+ Id = "user-abc",
+ UserName = "testuser",
+ Email = "test@example.com",
+ TenantId = "tenant-xyz",
+ SubscriptionPlanId = 1
+ };
+ context.Users.Add(user);
+ await context.SaveChangesAsync();
+ }
+
+ var command = new SubmitQuizResultCommand(
+ UserId: "user-abc",
+ Topic: "Sprawdzian: .NET 10",
+ Score: 4,
+ TotalQuestions: 5
+ );
+
+ var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object);
+
+ // Act
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+
+ using (var context = new AppDbContext(_contextOptions))
+ {
+ var quizResult = await context.QuizResults.FirstOrDefaultAsync(q => q.UserId == "user-abc");
+ quizResult.Should().NotBeNull();
+ quizResult!.Topic.Should().Be("Sprawdzian: .NET 10");
+ quizResult.Score.Should().Be(4);
+ quizResult.TotalQuestions.Should().Be(5);
+ quizResult.TenantId.Should().Be("tenant-xyz");
+ }
+ }
+
+ [Fact]
+ public async Task Handle_WithNonExistentUser_ReturnsFailureResult()
+ {
+ // Arrange
+ var command = new SubmitQuizResultCommand(
+ UserId: "non-existent",
+ Topic: "Sprawdzian: .NET 10",
+ Score: 4,
+ TotalQuestions: 5
+ );
+
+ var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object);
+
+ // Act
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.IsFailed.Should().BeTrue();
+ result.Errors.Should().ContainSingle(e => e.Message == "User not found.");
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+}
diff --git a/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs
new file mode 100644
index 0000000..e8afdf8
--- /dev/null
+++ b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs
@@ -0,0 +1,58 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using NexusReader.Data.Persistence;
+using Xunit;
+
+namespace NexusReader.Application.Tests.Queries;
+
+public class CheckDatabaseTest
+{
+ [Fact]
+ public async Task PrintDatabaseStats()
+ {
+ var configJson = await File.ReadAllTextAsync("../../../../../src/NexusReader.Web/appsettings.json");
+ var doc = JsonDocument.Parse(configJson);
+ var pgConn = doc.RootElement.GetProperty("ConnectionStrings").GetProperty("PostgresConnection").GetString();
+
+ Console.WriteLine($"Postgres Connection: {pgConn}");
+
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseNpgsql(pgConn);
+
+ using var context = new AppDbContext(optionsBuilder.Options);
+
+ var usersCount = await context.Users.CountAsync();
+ var ebooksCount = await context.Ebooks.CountAsync();
+ var unitsCount = await context.KnowledgeUnits.CountAsync();
+ var cacheCount = await context.SemanticKnowledgeCache.CountAsync();
+
+ Console.WriteLine($"=== DATABASE STATS ===");
+ Console.WriteLine($"Users: {usersCount}");
+ Console.WriteLine($"Ebooks: {ebooksCount}");
+ Console.WriteLine($"KnowledgeUnits: {unitsCount}");
+ Console.WriteLine($"SemanticKnowledgeCache: {cacheCount}");
+
+ var users = await context.Users.ToListAsync();
+ foreach (var u in users)
+ {
+ Console.WriteLine($"User: {u.Email}, TenantId: '{u.TenantId}'");
+ }
+
+ var ebooks = await context.Ebooks.ToListAsync();
+ foreach (var eb in ebooks)
+ {
+ Console.WriteLine($"Ebook Id: {eb.Id}, Title: '{eb.Title}', FilePath: '{eb.FilePath}', Ready: {eb.IsReadyForReading}");
+ }
+
+ var cache = await context.SemanticKnowledgeCache.ToListAsync();
+ foreach (var c in cache)
+ {
+ Console.WriteLine($"Cache Hash: {c.ContentHash}, TenantId: '{c.TenantId}', PromptVersion: {c.PromptVersion}, JsonData Preview: {c.JsonData.Substring(0, Math.Min(c.JsonData.Length, 150))}");
+ }
+
+ Assert.True(true);
+ }
+}