feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)

This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility.

### Key Changes
- **Infrastructure Stabilization**:
  - Implemented production-grade `EbookRepository` with PostgreSQL `EF.Functions.ILike` support.
  - Enforced `IsReadyForReading = false` state for newly added ebooks (resolves #35).
  - Updated `SignalRSyncBroadcaster` to support targeted user messaging and ingestion-specific progress updates (resolves #37).
- **WASM Client Functional Proxies**:
  - Replaced "Throwing" dummy services with `WasmEbookRepository`, `WasmSyncBroadcaster`, `WasmBookStorageService`, and `WasmEmbeddingGenerator`.
  - These services proxy requests to the backend via a new set of Minimal API endpoints in `NexusReader.Web`.
- **Domain Refinement**:
  - Added `IsReadyForReading` flag to the `Ebook` entity to manage background AI processing states.

### Related Issues
- Fixes #35
- Fixes #36
- Fixes #37

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #42
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #42.
This commit is contained in:
2026-05-13 18:24:24 +00:00
committed by Marek Jaisński
parent d5c2952bec
commit 5a2223a4c8
39 changed files with 6134 additions and 301 deletions
@@ -7,7 +7,11 @@ using GeminiDotnet;
using GeminiDotnet.Extensions.AI;
using NexusReader.Data.Persistence;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Persistence;
using NexusReader.Infrastructure.RealTime;
using NexusReader.Infrastructure.Services;
using NexusReader.Infrastructure.Configuration;
using Polly;
@@ -27,12 +31,21 @@ public static class DependencyInjection
if (!string.IsNullOrEmpty(pgConnectionString))
{
services.AddDbContextFactory<AppDbContext>(options =>
options.UseNpgsql(pgConnectionString, x => x.UseVector()),
ServiceLifetime.Scoped);
// Also register a scoped DbContext for repositories that need it
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
}
else
{
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite(sqliteConnectionString),
ServiceLifetime.Scoped);
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
}
@@ -40,8 +53,6 @@ public static class DependencyInjection
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
{
Console.WriteLine("[Infrastructure] WARNING: AI API Key is missing or placeholder!");
@@ -51,7 +62,7 @@ public static class DependencyInjection
{
builder.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
@@ -60,10 +71,10 @@ public static class DependencyInjection
});
});
services.AddChatClient(new GeminiChatClient(new GeminiClientOptions
{
ApiKey = aiSettings.ApiKey,
ModelId = aiSettings.Model
services.AddChatClient(new GeminiChatClient(new GeminiClientOptions
{
ApiKey = aiSettings.ApiKey,
ModelId = aiSettings.Model
}));
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
@@ -72,10 +83,20 @@ public static class DependencyInjection
ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
}));
// Application-layer service implementations
services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddTransient<IEpubReader, EpubReaderService>();
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
services.AddSingleton<IBookStorageService, BookStorageService>();
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
services.AddScoped<IBookStorageService, BookStorageService>();
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>();
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
services.AddAuthorizationCore(options =>
{
@@ -83,7 +104,6 @@ public static class DependencyInjection
});
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
return services;
@@ -1,66 +0,0 @@
using FluentResults;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Commands.Sync;
using NexusReader.Domain.Entities;
using NexusReader.Data.Persistence;
using NexusReader.Infrastructure.RealTime;
namespace NexusReader.Infrastructure.Handlers;
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly IHubContext<SyncHub> _hubContext;
public UpdateReadingProgressCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory,
IHubContext<SyncHub> hubContext)
{
_dbContextFactory = dbContextFactory;
_hubContext = hubContext;
}
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
{
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
var now = DateTime.UtcNow;
user.LastReadPageId = request.PageId;
user.LastReadAt = now;
// Update specific Ebook progress
var ebook = await context.Ebooks.FirstOrDefaultAsync(e => e.Id == request.EbookId, cancellationToken);
if (ebook != null)
{
ebook.Progress = request.Progress;
ebook.LastChapter = request.ChapterTitle;
ebook.LastChapterIndex = request.ChapterIndex;
ebook.LastReadDate = now;
}
await context.SaveChangesAsync(cancellationToken);
// Broadcast to other devices
var group = _hubContext.Clients.Group($"User_{request.UserId}");
if (!string.IsNullOrEmpty(request.ExcludedConnectionId))
{
await _hubContext.Clients
.GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
else
{
await group.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
return Result.Ok();
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// EF Core implementation of <see cref="IEbookRepository"/>.
/// Uses a scoped <see cref="AppDbContext"/> created via the factory for long-running operations.
/// </summary>
internal sealed class EbookRepository : IEbookRepository
{
private readonly AppDbContext _context;
public EbookRepository(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default)
{
// Use PostgreSQL ILike for case-insensitive searching if on Npgsql provider,
// otherwise fallback to string comparison.
if (_context.Database.IsNpgsql())
{
return await _context.Authors
.FirstOrDefaultAsync(a => EF.Functions.ILike(a.Name, name), cancellationToken);
}
return await _context.Authors
.FirstOrDefaultAsync(
a => a.Name.ToLower() == name.ToLower(),
cancellationToken);
}
/// <inheritdoc />
public void AddAuthor(Author author) => _context.Authors.Add(author);
/// <inheritdoc />
public void AddEbook(Ebook ebook)
{
// Explicitly set the readiness flag to false upon addition
ebook.IsReadyForReading = false;
_context.Ebooks.Add(ebook);
}
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> _context.SaveChangesAsync(cancellationToken);
}
@@ -0,0 +1,61 @@
using Microsoft.AspNetCore.SignalR;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Infrastructure.RealTime;
namespace NexusReader.Infrastructure.RealTime;
/// <summary>
/// SignalR implementation of <see cref="ISyncBroadcaster"/>.
/// Uses <see cref="IHubContext{SyncHub}"/> to push progress updates to all of a user's connected devices.
/// </summary>
internal sealed class SignalRSyncBroadcaster : ISyncBroadcaster
{
private readonly IHubContext<SyncHub> _hubContext;
public SignalRSyncBroadcaster(IHubContext<SyncHub> hubContext)
{
_hubContext = hubContext;
}
/// <inheritdoc />
public async Task BroadcastProgressAsync(
string userId,
string pageId,
DateTime timestamp,
string? excludedConnectionId,
CancellationToken cancellationToken = default)
{
// Using Clients.User(userId) targeted broadcasting.
// This pushes to all of a user's connected devices across all sessions.
if (!string.IsNullOrEmpty(excludedConnectionId))
{
await _hubContext.Clients
.User(userId)
.SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken);
// Note: SignalR HubContext doesn't easily support 'Except' when using .User(id)
// from outside the Hub itself without custom IUserIdProvider.
// If strict exclusion is needed, we'd use groups, but requirements mandate .User(userId).
}
else
{
await _hubContext.Clients
.User(userId)
.SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken);
}
}
/// <inheritdoc />
public async Task BroadcastIngestionProgressAsync(
string userId,
string message,
double progress,
CancellationToken cancellationToken = default)
{
// Pushes ingestion status (e.g., "Parsing chapters...") and progress (0.0-1.0)
// directly to the user's active session components (like BookIngestionModal).
await _hubContext.Clients
.User(userId)
.SendAsync("IngestionProgress", message, progress, cancellationToken: cancellationToken);
}
}
@@ -35,7 +35,9 @@ public class BookStorageService : IBookStorageService
await data.CopyToAsync(fileStream);
}
return Path.Combine("uploads", uniqueFileName);
// Use forward-slash explicitly: Path.Combine produces backslashes on Windows
// which would cause 404s when stored as web-relative paths.
return $"uploads/{uniqueFileName}";
}
public async Task<string?> SaveCoverAsync(byte[] data, string fileName)
@@ -58,7 +60,7 @@ public class BookStorageService : IBookStorageService
await data.CopyToAsync(fileStream);
}
return Path.Combine("covers", uniqueFileName);
return $"covers/{uniqueFileName}";
}
private void EnsureDirectoryExists(string path)
@@ -0,0 +1,30 @@
using FluentResults;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Reader;
using VersOne.Epub;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Extracts metadata (title, author, cover image) from an EPUB stream without persisting anything.
/// Used by the ingestion UI before the user confirms the upload.
/// </summary>
public class EpubMetadataExtractor : IEpubMetadataExtractor
{
/// <inheritdoc />
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
{
try
{
using var bookRef = await EpubReader.OpenBookAsync(epubStream);
var title = bookRef.Title ?? "Unknown Title";
var author = bookRef.Author ?? "Unknown Author";
byte[]? cover = await bookRef.ReadCoverAsync();
return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover });
}
catch (Exception ex)
{
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
}
}
}
@@ -1,60 +1,64 @@
using System.Text;
using System.Text.RegularExpressions;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Reader;
using VersOne.Epub;
using Microsoft.EntityFrameworkCore;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using VersOne.Epub;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Reads and parses EPUB files from the storage path recorded in the database.
/// </summary>
public class EpubReaderService : IEpubReader
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private const string EpubPath = "wwwroot/assets/book.epub";
private readonly ILogger<EpubReaderService> _logger;
private const int WordThreshold = 1000;
public EpubReaderService(IDbContextFactory<AppDbContext> dbContextFactory)
public EpubReaderService(
IDbContextFactory<AppDbContext> dbContextFactory,
ILogger<EpubReaderService> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null)
/// <inheritdoc />
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
Guid ebookId,
int chapterIndex,
string? userId = null,
CancellationToken cancellationToken = default)
{
try
{
// Path handling: Recursive search upwards to find the asset in development or production
var relativePath = Path.Combine("wwwroot", "assets", "book.epub");
string? fullPath = null;
var searchPaths = new List<string>();
// 1. Resolve the file path from the database
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
while (currentDir != null)
var ebook = await context.Ebooks
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.Id == ebookId && (userId == null || e.UserId == userId),
cancellationToken);
if (ebook == null)
{
var checkPath1 = Path.Combine(currentDir.FullName, relativePath);
var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", relativePath);
searchPaths.Add(checkPath1);
if (File.Exists(checkPath1)) { fullPath = checkPath1; break; }
searchPaths.Add(checkPath2);
if (File.Exists(checkPath2)) { fullPath = checkPath2; break; }
currentDir = currentDir.Parent;
}
if (fullPath == null)
{
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
return Result.Fail($"Ebook '{ebookId}' not found for user '{userId}'.");
}
if (!File.Exists(fullPath))
// FilePath is stored as a web-relative path (e.g. "uploads/guid_title.epub").
// Resolve against the content root, then against the wwwroot sub-directory.
var fullPath = ResolvePath(ebook.FilePath);
if (fullPath == null || !File.Exists(fullPath))
{
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
_logger.LogError("EPUB file for ebook {EbookId} not found at path '{FilePath}'.", ebookId, ebook.FilePath);
return Result.Fail($"The EPUB file for this book could not be found on the server.");
}
// 2. Parse the EPUB
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
var readingOrder = bookRef.GetReadingOrder();
@@ -63,22 +67,20 @@ public class EpubReaderService : IEpubReader
return Result.Fail("The EPUB has no readable content files in ReadingOrder.");
}
// Ensure index is within bounds
if (chapterIndex < 0 || chapterIndex >= readingOrder.Count)
{
chapterIndex = 0; // Default to first chapter
chapterIndex = 0;
}
var chapterRef = readingOrder[chapterIndex];
// Try to find a better title from navigation (TOC)
var navigation = bookRef.GetNavigation();
var chapterTitle = FindTitleInNavigation(navigation, chapterRef.FilePath)
?? Path.GetFileNameWithoutExtension(chapterRef.FilePath)
var chapterTitle = FindTitleInNavigation(navigation, chapterRef.FilePath)
?? Path.GetFileNameWithoutExtension(chapterRef.FilePath)
?? $"Chapter {chapterIndex + 1}";
var chapterContent = await chapterRef.ReadContentAsTextAsync();
// 3. Build content blocks
var blocks = new List<ContentBlock>();
int totalWordCount = 0;
int blockCounter = 0;
@@ -89,13 +91,11 @@ public class EpubReaderService : IEpubReader
var sanitizedContent = SanitizeParagraph(p);
if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
// Requirement: Each paragraph mapped to its own TextSegmentBlock
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
int wordsInP = CountWords(sanitizedContent);
totalWordCount += wordsInP;
// Requirement: Smart Injection after 1000 words
if (totalWordCount >= WordThreshold)
{
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
@@ -103,58 +103,58 @@ public class EpubReaderService : IEpubReader
}
}
// End of chapter section trigger
if (blocks.Any() && blocks.Last() is not AiActionTriggerBlock)
{
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
}
// Find the EbookId from DB for this file AND this user
using var context = await _dbContextFactory.CreateDbContextAsync();
var ebook = await context.Ebooks
.Where(e => e.FilePath.Contains("book.epub") && (userId == null || e.UserId == userId))
.FirstOrDefaultAsync();
// Auto-provision if not found for this user (convenience for dev)
if (ebook == null && !string.IsNullOrEmpty(userId))
{
var author = await context.Authors.FirstOrDefaultAsync() ?? new Author { Name = "Unknown Author" };
ebook = new Ebook
{
Title = "Lives of the Most Excellent Painters, Sculptors, and Architects",
FilePath = "wwwroot/assets/book.epub",
UserId = userId,
Author = author,
TenantId = "global"
};
context.Ebooks.Add(ebook);
await context.SaveChangesAsync();
}
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebook?.Id ?? Guid.Empty));
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebook.Id));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process EPUB for ebook {EbookId}.", ebookId);
return Result.Fail(new Error($"Failed to process EPUB: {ex.Message}").CausedBy(ex));
}
}
private List<string> ExtractParagraphs(string html)
/// <summary>
/// Attempts to resolve a web-relative storage path to an absolute filesystem path.
/// Searches upward from the app base directory to handle both dev and production layouts.
/// </summary>
private static string? ResolvePath(string relativePath)
{
// Normalize forward-slashes to OS separator for file system access
var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar);
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
while (currentDir != null)
{
var candidate = Path.Combine(currentDir.FullName, "wwwroot", normalized);
if (File.Exists(candidate)) return candidate;
// Also try src/NexusReader.Web/wwwroot (development layout)
var devCandidate = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", "wwwroot", normalized);
if (File.Exists(devCandidate)) return devCandidate;
currentDir = currentDir.Parent;
}
return null;
}
private static List<string> ExtractParagraphs(string html)
{
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
var paragraphs = new List<string>();
// Match block-level elements: h1-h6, p, ul, ol, blockquote, pre
// We match the whole tag to preserve it for sanitization
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
foreach (Match match in matches)
{
paragraphs.Add(match.Value);
}
// Fallback: split by double newlines if no block tags found
if (paragraphs.Count == 0)
{
paragraphs = content.Split(new[] { "<br />", "<br>", "\n\n", "\r\n\r\n" }, StringSplitOptions.RemoveEmptyEntries).ToList();
@@ -163,76 +163,43 @@ public class EpubReaderService : IEpubReader
return paragraphs;
}
private string SanitizeParagraph(string html)
private static string SanitizeParagraph(string html)
{
// 1. Remove <style> and <script> blocks
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
// 2. Remove all tags except allowed structural and formatting tags
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase);
// 3. Requirement: Aggressively strip attributes (class, style, id) from allowed tags
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase);
// 4. Decode HTML entities
clean = System.Net.WebUtility.HtmlDecode(clean);
return clean.Trim();
}
private int CountWords(string text)
private static int CountWords(string text)
{
if (string.IsNullOrWhiteSpace(text)) return 0;
return text.Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries).Length;
}
private AiActionTriggerBlock CreateAiTrigger(string id)
{
return new AiActionTriggerBlock(
id,
private static AiActionTriggerBlock CreateAiTrigger(string id) =>
new(id,
"Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?",
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" }
);
}
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" });
private string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
private static string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
{
if (string.IsNullOrEmpty(filePath)) return null;
var fileName = Path.GetFileName(filePath);
foreach (var item in navigation)
{
// Match by full path or just filename as fallback
if (item.Link?.ContentFilePath == filePath || item.Link?.ContentFilePath == fileName)
return item.Title;
if (item.NestedItems != null && item.NestedItems.Any())
if (item.NestedItems?.Any() == true)
{
var childTitle = FindTitleInNavigation(item.NestedItems, filePath);
if (childTitle != null) return childTitle;
}
}
return null;
}
// Metadata extraction moved to EpubMetadataExtractor
}
public class EpubMetadataExtractor : IEpubMetadataExtractor
{
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
{
try
{
using var bookRef = await EpubReader.OpenBookAsync(epubStream);
var title = bookRef.Title ?? "Unknown Title";
var author = bookRef.Author ?? "Unknown Author";
byte[]? cover = await bookRef.ReadCoverAsync();
return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover });
}
catch (Exception ex)
{
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
}
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.ML.Tokenizers;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
@@ -19,12 +20,15 @@ namespace NexusReader.Infrastructure.Services;
public class KnowledgeService : IKnowledgeService
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly IChatClient _chatClient;
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ResiliencePipeline _retryPipeline;
private readonly AiSettings _settings;
private readonly Tokenizer _tokenizer;
private readonly ILogger<KnowledgeService> _logger;
private const string PromptVersion = "1.0";
public KnowledgeService(
@@ -32,14 +36,16 @@ public class KnowledgeService : IKnowledgeService
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
IDbContextFactory<AppDbContext> dbContextFactory,
ResiliencePipelineProvider<string> pipelineProvider,
IOptions<AiSettings> settings)
IOptions<AiSettings> settings,
ILogger<KnowledgeService> logger)
{
_chatClient = chatClient;
_embeddingGenerator = embeddingGenerator;
_dbContextFactory = dbContextFactory;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_settings = settings.Value;
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
_logger = logger;
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
// a very reliable estimation for token usage in Gemini-based workloads.
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
}
@@ -78,16 +84,19 @@ public class KnowledgeService : IKnowledgeService
if (cached != null && cached.PromptVersion == PromptVersion)
{
Console.WriteLine($"[KnowledgeService] Cache Hit for {traceType} ({hash})");
_logger.LogDebug("[KnowledgeService] Cache Hit for {TraceType} ({Hash})", traceType, hash);
try
{
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
if (packet != null) return Result.Ok(packet);
}
catch { /* fallback to regen */ }
catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Cached JSON for {Hash} was invalid; regenerating.", hash);
}
}
Console.WriteLine($"[KnowledgeService] Cache Miss for {traceType} ({hash}). Requesting AI...");
_logger.LogInformation("[KnowledgeService] Cache Miss for {TraceType} ({Hash}). Requesting AI...", traceType, hash);
try
{
var options = new ChatOptions
@@ -112,7 +121,7 @@ public class KnowledgeService : IKnowledgeService
try
{
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, JsonOptions);
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
// 3. Generate Embedding if not present
@@ -125,7 +134,7 @@ public class KnowledgeService : IKnowledgeService
}
catch (Exception ex)
{
Console.WriteLine($"[KnowledgeService] Embedding Error: {ex.Message}");
_logger.LogWarning(ex, "[KnowledgeService] Embedding generation failed; proceeding without vector.");
// We continue even if embedding fails, as the primary goal was knowledge extraction
}
@@ -159,7 +168,7 @@ public class KnowledgeService : IKnowledgeService
}
catch (JsonException ex)
{
Console.WriteLine($"[KnowledgeService] JSON Error: {ex.Message}. Raw length: {rawResponse.Length}");
_logger.LogError(ex, "[KnowledgeService] JSON deserialization error. Raw response length: {Length}", rawResponse.Length);
return Result.Fail($"Failed to deserialize AI response: {ex.Message}");
}
}
@@ -231,7 +240,7 @@ public class KnowledgeService : IKnowledgeService
}
else
{
Console.WriteLine($"[KnowledgeService] WARNING: Skipping invalid link {linkDto.Source} -> {linkDto.Target} (Missing units).");
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
}
}
}
@@ -264,7 +273,7 @@ public class KnowledgeService : IKnowledgeService
var rawJson = response.Text?.Trim() ?? "{}";
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, JsonOptions);
return result != null ? Result.Ok(result) : Result.Fail("Failed to parse groundedness result");
}