feat(ai): semantic-search backfill + Ollama profile (#30)
CI / backend (push) Successful in 55s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 55s
Deploy Staging / deploy (push) Successful in 42s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 58s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 55s
CI / backend (push) Successful in 55s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 55s
Deploy Staging / deploy (push) Successful in 42s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 58s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 55s
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Ai;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic-search backfill: the worker embeds emails lacking an Embedding and persists the
|
||||
/// vectors. Runs against live Postgres (pgvector) since the Embedding column is ignored under
|
||||
/// the InMemory provider; the CI db-tests job provides the database. Uses a deterministic fake
|
||||
/// embedding provider — real-Ollama integration is verified separately (endpoint contract:
|
||||
/// /api/embeddings, 768 dims).
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveDb")]
|
||||
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
|
||||
public class EmbeddingBackfillTests
|
||||
{
|
||||
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
|
||||
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId => Guid.Empty; // worker scope sees all rows
|
||||
public bool IsAuthenticated => false;
|
||||
}
|
||||
|
||||
private sealed class FakeEmbeddings : IEmbeddingProvider
|
||||
{
|
||||
public bool IsAvailable => true;
|
||||
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
|
||||
=> Task.FromResult(Vec(text));
|
||||
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<float[]>>(texts.Select(Vec).ToList());
|
||||
private static float[] Vec(string text)
|
||||
{
|
||||
var v = new float[768];
|
||||
v[0] = text.Length; // deterministic, content-dependent
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_embeds_pending_emails_and_persists_vectors()
|
||||
{
|
||||
if (Conn is null) return; // soft-skip outside the live-db CI job
|
||||
var uid = Guid.NewGuid();
|
||||
var opts = new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(Conn!, o => o.UseVector()).Options;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<ICurrentUser, FakeCurrentUser>();
|
||||
services.AddScoped(_ => new AppDbContext(opts, new FakeCurrentUser()));
|
||||
services.AddScoped<IEmbeddingProvider, FakeEmbeddings>();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||
{
|
||||
await seed.Database.MigrateAsync();
|
||||
seed.Users.Add(new User { Id = uid, GoogleSubjectId = "g" + uid, Email = uid + "@t.t" });
|
||||
var dom = new MailDomain { UserId = uid, Name = "t.t" };
|
||||
var snd = new Sender { UserId = uid, Address = "a@t.t", Domain = dom };
|
||||
var thr = new MailThread { UserId = uid, GmailThreadId = "th" + uid };
|
||||
seed.AddRange(dom, snd, thr);
|
||||
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e1" + uid, Subject = "hello world", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
|
||||
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e2" + uid, Subject = "quarterly invoice", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
try
|
||||
{
|
||||
var worker = new EmbeddingBackfillWorker(
|
||||
sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<EmbeddingBackfillWorker>.Instance);
|
||||
var processed = await worker.ProcessBatchAsync(CancellationToken.None);
|
||||
processed.Should().BeGreaterThanOrEqualTo(2);
|
||||
|
||||
using var check = new AppDbContext(opts, new FakeCurrentUser());
|
||||
var mine = await check.Emails.Where(e => e.UserId == uid).ToListAsync();
|
||||
mine.Should().OnlyContain(e => e.Embedding != null);
|
||||
mine.First().Embedding!.ToArray().Length.Should().Be(768);
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var c = new AppDbContext(opts, new FakeCurrentUser());
|
||||
await c.Emails.Where(e => e.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Threads.Where(t => t.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Senders.Where(s => s.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Domains.Where(d => d.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Users.Where(u => u.Id == uid).ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ namespace InboxIntel.IntegrationTests;
|
||||
/// InMemory test run is unaffected.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveDb")]
|
||||
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
|
||||
public class LiveDbSearchTests
|
||||
{
|
||||
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
|
||||
|
||||
Reference in New Issue
Block a user