refactor: move authentication JS to external file, add book description persistence, and implement semantic search fallback tests
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
# 📖 Nexus Reader
|
||||||
|
|
||||||
|
Nexus Reader is a state-of-the-art, cross-platform Blazor .NET 10 immersive e-book reader, powered by **Native AOT**, **Clean Architecture**, **CQRS**, and interactive **D3.js Relationship Graphs** built on vector-based AI semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features & Architecture Highlights
|
||||||
|
|
||||||
|
### 📁 Ingestion & Description persistence
|
||||||
|
- Extracted and persistent **book descriptions** from EPUB package metadata during book ingestion.
|
||||||
|
- The `Description` field propagates cleanly from the `Ebook` entity through Mapster to `LastReadBookDto` and `UserProfileDto`.
|
||||||
|
|
||||||
|
### 🔗 Deep-Link Routing
|
||||||
|
- Implemented deep-link route activation: `/reader/{bookId}?chapter=N`.
|
||||||
|
- Allows instant resume of reading session coordinates and loads the specific chapter chapter directly via URL query parameters.
|
||||||
|
|
||||||
|
### 🛡️ Downstream AI Resilience
|
||||||
|
- Standard resilience engine in `DependencyInjection.cs` utilizing the **Polly** package (`ai-retry`).
|
||||||
|
- Automatically intercepts, handles, and retries on both rate-limits (`429 Too Many Requests`) and downstream capacity overloads (`503 ServiceUnavailable` / `high demand`).
|
||||||
|
|
||||||
|
### ⚙️ Concurrent Request Deduplication
|
||||||
|
- Multi-client InteractiveAuto Blazor circuit synchronization is backed by a thread-safe active task registry in `KnowledgeService` which ensures that identical concurrent requests await a single shared task instance, eliminating redundant LLM queries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Build & Verification Gate
|
||||||
|
|
||||||
|
Ensure the dotnet workload matches the active SDK, and compile the full solution utilizing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build NexusReader.slnx --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Run test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test --no-restore
|
||||||
|
```
|
||||||
@@ -13,7 +13,8 @@ public static class MappingConfig
|
|||||||
var config = TypeAdapterConfig.GlobalSettings;
|
var config = TypeAdapterConfig.GlobalSettings;
|
||||||
|
|
||||||
config.NewConfig<NexusUser, UserProfileDto>();
|
config.NewConfig<NexusUser, UserProfileDto>();
|
||||||
// Roles are mapped manually in queries due to Identity structure
|
config.NewConfig<Ebook, LastReadBookDto>()
|
||||||
|
.Map(dest => dest.Description, src => src.Description);
|
||||||
|
|
||||||
services.AddSingleton(config);
|
services.AddSingleton(config);
|
||||||
services.AddScoped<IMapper, ServiceMapper>();
|
services.AddScoped<IMapper, ServiceMapper>();
|
||||||
@@ -21,3 +22,4 @@ public static class MappingConfig
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using MediatR;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
using Pgvector;
|
using Pgvector;
|
||||||
using Pgvector.EntityFrameworkCore;
|
using Pgvector.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -46,12 +46,30 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
|||||||
var queryVector768 = new Vector(embeddingResponse768.First().Vector.ToArray());
|
var queryVector768 = new Vector(embeddingResponse768.First().Vector.ToArray());
|
||||||
|
|
||||||
// 2. Perform Cosine Similarity Search on Knowledge Units
|
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||||
var candidates = await dbContext.KnowledgeUnits
|
List<KnowledgeUnit> candidates;
|
||||||
.AsNoTracking()
|
bool isSqlite = dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite";
|
||||||
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
|
||||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector768))
|
if (isSqlite)
|
||||||
.Take(request.Limit)
|
{
|
||||||
.ToListAsync(cancellationToken);
|
var allUnits = await dbContext.KnowledgeUnits
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
candidates = allUnits
|
||||||
|
.OrderBy(x => CalculateCosineDistance(x.Vector!, queryVector768))
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
candidates = await dbContext.KnowledgeUnits
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
||||||
|
.OrderBy(x => x.Vector!.CosineDistance(queryVector768))
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (!candidates.Any())
|
if (!candidates.Any())
|
||||||
{
|
{
|
||||||
@@ -62,18 +80,34 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
|||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
var queryVector1536 = new Vector(embeddingResponse1536.First().Vector.ToArray());
|
var queryVector1536 = new Vector(embeddingResponse1536.First().Vector.ToArray());
|
||||||
|
|
||||||
var legacyResults = await dbContext.SemanticKnowledgeCache
|
List<SemanticKnowledgeCache> legacyResults;
|
||||||
.AsNoTracking()
|
if (isSqlite)
|
||||||
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
{
|
||||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector1536))
|
var allCache = await dbContext.SemanticKnowledgeCache
|
||||||
.Take(request.Limit)
|
.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
legacyResults = allCache
|
||||||
|
.OrderBy(x => CalculateCosineDistance(x.Vector!, queryVector1536))
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
legacyResults = await dbContext.SemanticKnowledgeCache
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||||
|
.OrderBy(x => x.Vector!.CosineDistance(queryVector1536))
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.Ok(legacyResults.Select(r => new SemanticSearchResultDto
|
return Result.Ok(legacyResults.Select(r => new SemanticSearchResultDto
|
||||||
{
|
{
|
||||||
ContentHash = r.ContentHash,
|
ContentHash = r.ContentHash,
|
||||||
Snippet = r.OriginalText,
|
Snippet = r.OriginalText,
|
||||||
RelevanceScore = (float)(1 - r.Vector!.CosineDistance(queryVector1536))
|
RelevanceScore = (float)(1 - (isSqlite ? CalculateCosineDistance(r.Vector!, queryVector1536) : r.Vector!.CosineDistance(queryVector1536)))
|
||||||
}).ToList());
|
}).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +129,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
|||||||
{
|
{
|
||||||
var dto = new SemanticSearchResultDto
|
var dto = new SemanticSearchResultDto
|
||||||
{
|
{
|
||||||
ContentHash = c.Id,
|
ContentHash = c.Id.ToString(),
|
||||||
Snippet = c.Content,
|
Snippet = c.Content,
|
||||||
UnitType = c.Type.ToString(),
|
UnitType = c.Type.ToString(),
|
||||||
RelevanceScore = (float)(1 - c.Vector!.CosineDistance(queryVector768)),
|
RelevanceScore = (float)(1 - (isSqlite ? CalculateCosineDistance(c.Vector!, queryVector768) : c.Vector!.CosineDistance(queryVector768))),
|
||||||
Metadata = string.IsNullOrEmpty(c.MetadataJson)
|
Metadata = string.IsNullOrEmpty(c.MetadataJson)
|
||||||
? null
|
? null
|
||||||
: JsonSerializer.Deserialize<Dictionary<string, object>>(c.MetadataJson)
|
: JsonSerializer.Deserialize<Dictionary<string, object>>(c.MetadataJson)
|
||||||
@@ -124,4 +158,22 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
|||||||
return Result.Fail(new Error("Failed to perform semantic search").CausedBy(ex));
|
return Result.Fail(new Error("Failed to perform semantic search").CausedBy(ex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double CalculateCosineDistance(Vector v1, Vector v2)
|
||||||
|
{
|
||||||
|
var a = v1.ToArray();
|
||||||
|
var b = v2.ToArray();
|
||||||
|
if (a.Length != b.Length) return 1.0;
|
||||||
|
double dotProduct = 0;
|
||||||
|
double l1 = 0;
|
||||||
|
double l2 = 0;
|
||||||
|
for (int i = 0; i < a.Length; i++)
|
||||||
|
{
|
||||||
|
dotProduct += a[i] * b[i];
|
||||||
|
l1 += a[i] * a[i];
|
||||||
|
l2 += b[i] * b[i];
|
||||||
|
}
|
||||||
|
if (l1 == 0 || l2 == 0) return 1.0;
|
||||||
|
return 1.0 - (dotProduct / (Math.Sqrt(l1) * Math.Sqrt(l2)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Persistence;
|
namespace NexusReader.Data.Persistence;
|
||||||
@@ -30,8 +31,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.HasPostgresExtension("vector");
|
|
||||||
|
|
||||||
modelBuilder.Entity<NexusUser>(entity =>
|
modelBuilder.Entity<NexusUser>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||||
@@ -53,26 +52,59 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
entity.HasIndex(p => p.PlanName).IsUnique();
|
entity.HasIndex(p => p.PlanName).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
if (Database.IsSqlite())
|
||||||
{
|
{
|
||||||
entity.HasKey(e => e.ContentHash);
|
var vectorConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<Vector, string>(
|
||||||
entity.HasIndex(e => e.ContentHash).IsUnique();
|
v => v != null ? string.Join(",", v.ToArray()) : string.Empty,
|
||||||
entity.HasIndex(e => e.TenantId);
|
s => !string.IsNullOrEmpty(s) ? new Vector(s.Split(',').Select(float.Parse).ToArray()) : null!
|
||||||
entity.Property(e => e.Vector).HasColumnType("vector(1536)");
|
);
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.ContentHash);
|
||||||
|
entity.HasIndex(e => e.ContentHash).IsUnique();
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
|
entity.Property(e => e.Vector).HasConversion(vectorConverter);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
|
entity.HasIndex(e => e.EbookId);
|
||||||
|
entity.Property(e => e.Vector).HasConversion(vectorConverter);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Ebook)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.EbookId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
entity.HasKey(e => e.Id);
|
modelBuilder.HasPostgresExtension("vector");
|
||||||
entity.HasIndex(e => e.TenantId);
|
|
||||||
entity.HasIndex(e => e.EbookId);
|
|
||||||
entity.Property(e => e.Vector).HasColumnType("vector(768)");
|
|
||||||
|
|
||||||
entity.HasOne(e => e.Ebook)
|
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
||||||
.WithMany()
|
{
|
||||||
.HasForeignKey(e => e.EbookId)
|
entity.HasKey(e => e.ContentHash);
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
entity.HasIndex(e => e.ContentHash).IsUnique();
|
||||||
});
|
entity.HasIndex(e => e.TenantId);
|
||||||
|
entity.Property(e => e.Vector).HasColumnType("vector(1536)");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.TenantId);
|
||||||
|
entity.HasIndex(e => e.EbookId);
|
||||||
|
entity.Property(e => e.Vector).HasColumnType("vector(768)");
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Ebook)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.EbookId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
|
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
window.nexusAuth = {
|
||||||
|
submitLoginForm: function (formId, email, password, rememberMe) {
|
||||||
|
var form = document.getElementById(formId);
|
||||||
|
if (!form) return false;
|
||||||
|
|
||||||
|
var emailInput = form.querySelector('input[name="email"]');
|
||||||
|
var passwordInput = form.querySelector('input[name="password"]');
|
||||||
|
var rememberMeInput = form.querySelector('input[name="rememberMe"]');
|
||||||
|
|
||||||
|
if (emailInput) emailInput.value = email;
|
||||||
|
if (passwordInput) passwordInput.value = password;
|
||||||
|
if (rememberMeInput) rememberMeInput.value = rememberMe ? "true" : "false";
|
||||||
|
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -41,25 +41,8 @@
|
|||||||
// Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway
|
// Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway
|
||||||
setTimeout(hidePreloader, 3000);
|
setTimeout(hidePreloader, 3000);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
window.nexusAuth = {
|
|
||||||
submitLoginForm: function (formId, email, password, rememberMe) {
|
|
||||||
var form = document.getElementById(formId);
|
|
||||||
if (!form) return false;
|
|
||||||
|
|
||||||
var emailInput = form.querySelector('input[name="email"]');
|
|
||||||
var passwordInput = form.querySelector('input[name="password"]');
|
|
||||||
var rememberMeInput = form.querySelector('input[name="rememberMe"]');
|
|
||||||
|
|
||||||
if (emailInput) emailInput.value = email;
|
|
||||||
if (passwordInput) passwordInput.value = password;
|
|
||||||
if (rememberMeInput) rememberMeInput.value = rememberMe ? "true" : "false";
|
|
||||||
|
|
||||||
form.submit();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="_content/NexusReader.UI.Shared/js/auth.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Moq;
|
||||||
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
using NexusReader.Application.DTOs.User;
|
||||||
|
using NexusReader.Application.Queries.Library;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
using Pgvector;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Tests.Queries;
|
||||||
|
|
||||||
|
public class QueryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly DbContextOptions<AppDbContext> _contextOptions;
|
||||||
|
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
||||||
|
private readonly Mock<IEmbeddingGenerator<string, Embedding<float>>> _embeddingGeneratorMock;
|
||||||
|
|
||||||
|
public QueryTests()
|
||||||
|
{
|
||||||
|
_connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
_connection.Open();
|
||||||
|
|
||||||
|
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(_connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
// Seed initial database schema
|
||||||
|
using var context = new AppDbContext(_contextOptions);
|
||||||
|
context.Database.EnsureCreated();
|
||||||
|
|
||||||
|
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
|
||||||
|
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() => new AppDbContext(_contextOptions));
|
||||||
|
_dbContextFactoryMock.Setup(f => f.CreateDbContext())
|
||||||
|
.Returns(() => new AppDbContext(_contextOptions));
|
||||||
|
|
||||||
|
_embeddingGeneratorMock = new Mock<IEmbeddingGenerator<string, Embedding<float>>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMyEbooksQuery_WithPopulatedDescription_ReturnsCorrectDescription()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using (var context = new AppDbContext(_contextOptions))
|
||||||
|
{
|
||||||
|
var user = new NexusUser
|
||||||
|
{
|
||||||
|
Id = "user-123",
|
||||||
|
UserName = "testuser",
|
||||||
|
Email = "test@example.com",
|
||||||
|
TenantId = "tenant-123",
|
||||||
|
SubscriptionPlanId = 1
|
||||||
|
};
|
||||||
|
context.Users.Add(user);
|
||||||
|
|
||||||
|
var author = new Author { Id = 1, Name = "Adam Mickiewicz" };
|
||||||
|
context.Authors.Add(author);
|
||||||
|
|
||||||
|
var ebook = new Ebook
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = "user-123",
|
||||||
|
Title = "Pan Tadeusz",
|
||||||
|
AuthorId = author.Id,
|
||||||
|
Description = "A Polish epic poem written by Adam Mickiewicz.",
|
||||||
|
CoverUrl = "cover.png",
|
||||||
|
Progress = 42.5,
|
||||||
|
LastChapter = "Księga I",
|
||||||
|
LastChapterIndex = 1,
|
||||||
|
AddedDate = DateTime.UtcNow,
|
||||||
|
LastReadDate = DateTime.UtcNow,
|
||||||
|
FilePath = "dummy.epub"
|
||||||
|
};
|
||||||
|
context.Ebooks.Add(ebook);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = new GetMyEbooksQueryHandler(_dbContextFactoryMock.Object);
|
||||||
|
var query = new GetMyEbooksQuery("user-123");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await handler.Handle(query, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Value.Should().HaveCount(1);
|
||||||
|
result.Value.First().Title.Should().Be("Pan Tadeusz");
|
||||||
|
result.Value.First().Description.Should().Be("A Polish epic poem written by Adam Mickiewicz.");
|
||||||
|
result.Value.First().Progress.Should().Be(42.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchLibrarySemanticallyQuery_WithEmptyQueryText_ReturnsFailure()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var handler = new SearchLibrarySemanticallyQueryHandler(_dbContextFactoryMock.Object, _embeddingGeneratorMock.Object);
|
||||||
|
var query = new SearchLibrarySemanticallyQuery("", "tenant-123");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await handler.Handle(query, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.Should().BeFalse();
|
||||||
|
result.Errors.First().Message.Should().Be("Query text cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchLibrarySemanticallyQuery_WithNoResults_TriggersFallback1536Embedding()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Mock 768-dim primary embedding generator response
|
||||||
|
var embedding768 = new Embedding<float>(new float[768]);
|
||||||
|
var mockResponse768 = new GeneratedEmbeddings<Embedding<float>>(new List<Embedding<float>> { embedding768 });
|
||||||
|
_embeddingGeneratorMock.Setup(g => g.GenerateAsync(
|
||||||
|
It.Is<IEnumerable<string>>(s => s.Contains("test")),
|
||||||
|
It.Is<EmbeddingGenerationOptions>(o => o.Dimensions == 768),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(mockResponse768);
|
||||||
|
|
||||||
|
// Mock 1536-dim fallback embedding generator response
|
||||||
|
var embedding1536 = new Embedding<float>(new float[1536]);
|
||||||
|
var mockResponse1536 = new GeneratedEmbeddings<Embedding<float>>(new List<Embedding<float>> { embedding1536 });
|
||||||
|
_embeddingGeneratorMock.Setup(g => g.GenerateAsync(
|
||||||
|
It.Is<IEnumerable<string>>(s => s.Contains("test")),
|
||||||
|
It.Is<EmbeddingGenerationOptions>(o => o.Dimensions == 1536),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(mockResponse1536);
|
||||||
|
|
||||||
|
// Seed one legacy cache entry
|
||||||
|
using (var context = new AppDbContext(_contextOptions))
|
||||||
|
{
|
||||||
|
var cacheEntry = new SemanticKnowledgeCache
|
||||||
|
{
|
||||||
|
TenantId = "tenant-123",
|
||||||
|
ContentHash = "hash-123",
|
||||||
|
OriginalText = "Fallback Cache Content Snippet",
|
||||||
|
Vector = new Vector(new float[1536]),
|
||||||
|
PromptVersion = "1",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
context.SemanticKnowledgeCache.Add(cacheEntry);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = new SearchLibrarySemanticallyQueryHandler(_dbContextFactoryMock.Object, _embeddingGeneratorMock.Object);
|
||||||
|
var query = new SearchLibrarySemanticallyQuery("test", "tenant-123");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await handler.Handle(query, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Value.Should().HaveCount(1);
|
||||||
|
result.Value.First().Snippet.Should().Be("Fallback Cache Content Snippet");
|
||||||
|
result.Value.First().ContentHash.Should().Be("hash-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_connection.Close();
|
||||||
|
_connection.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user