5191dd010f
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
105 lines
4.2 KiB
C#
105 lines
4.2 KiB
C#
using InboxIntel.Application.Abstractions;
|
|
using InboxIntel.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace InboxIntel.Infrastructure.Ai;
|
|
|
|
/// <summary>
|
|
/// Fills <c>Email.Embedding</c> (pgvector) for semantic search, in small background batches
|
|
/// so interactive requests are never starved (per docs/discovery/06: embeddings are the
|
|
/// small always-on model; the batch pause keeps VRAM/CPU pressure low). Exits immediately
|
|
/// when the embedding provider is unavailable (AI disabled / Ollama down) — semantic search
|
|
/// simply stays dormant and lexical search is unaffected.
|
|
/// </summary>
|
|
public class EmbeddingBackfillWorker : BackgroundService
|
|
{
|
|
private const int BatchSize = 32;
|
|
private static readonly TimeSpan BatchPause = TimeSpan.FromSeconds(2);
|
|
private static readonly TimeSpan IdleRescan = TimeSpan.FromMinutes(15);
|
|
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly ILogger<EmbeddingBackfillWorker> _logger;
|
|
|
|
public EmbeddingBackfillWorker(IServiceScopeFactory scopeFactory, ILogger<EmbeddingBackfillWorker> logger)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
// Provider availability is fixed by configuration for the process lifetime.
|
|
using (var probe = _scopeFactory.CreateScope())
|
|
{
|
|
if (!probe.ServiceProvider.GetRequiredService<IEmbeddingProvider>().IsAvailable)
|
|
{
|
|
_logger.LogDebug("EmbeddingBackfillWorker idle: no embedding provider (AI disabled).");
|
|
return;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("EmbeddingBackfillWorker started (batch {Batch}, pause {Pause}s)",
|
|
BatchSize, BatchPause.TotalSeconds);
|
|
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
int processed;
|
|
try
|
|
{
|
|
processed = await ProcessBatchAsync(stoppingToken);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
|
catch (Exception ex)
|
|
{
|
|
// Ollama hiccups must never crash the host; back off and retry.
|
|
_logger.LogWarning(ex, "Embedding batch failed; retrying after idle pause.");
|
|
processed = 0;
|
|
}
|
|
|
|
await Task.Delay(processed > 0 ? BatchPause : IdleRescan, stoppingToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>Embeds one batch. Public-ish (internal) for direct testing.</summary>
|
|
internal async Task<int> ProcessBatchAsync(CancellationToken ct)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var embeddings = scope.ServiceProvider.GetRequiredService<IEmbeddingProvider>();
|
|
|
|
var batch = await db.Emails
|
|
.Where(e => e.Embedding == null)
|
|
.OrderByDescending(e => e.SentAtUtc) // newest mail becomes searchable first
|
|
.Take(BatchSize)
|
|
.ToListAsync(ct);
|
|
if (batch.Count == 0) return 0;
|
|
|
|
// Subject + snippet is the semantic core; bodies are noisy (signatures, quoting)
|
|
// and slow to embed. Truncate defensively to keep well inside the model context.
|
|
var texts = batch
|
|
.Select(e => Truncate($"{e.Subject}\n{e.Snippet ?? e.BodyText}", 2000))
|
|
.ToList();
|
|
var vectors = await embeddings.EmbedBatchAsync(texts, ct);
|
|
if (vectors.Count != batch.Count)
|
|
{
|
|
_logger.LogWarning("Embedding batch returned {Got} vectors for {Want} emails; skipping batch.",
|
|
vectors.Count, batch.Count);
|
|
return 0;
|
|
}
|
|
|
|
for (var i = 0; i < batch.Count; i++)
|
|
{
|
|
if (vectors[i].Length == 0) continue; // provider soft-failure for one item
|
|
batch[i].Embedding = new Pgvector.Vector(vectors[i]);
|
|
}
|
|
await db.SaveChangesAsync(ct);
|
|
_logger.LogDebug("Embedded {Count} emails", batch.Count);
|
|
return batch.Count;
|
|
}
|
|
|
|
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
|
|
}
|