diff --git a/src/InboxIntel.Application/Abstractions/IServices.cs b/src/InboxIntel.Application/Abstractions/IServices.cs index 2f77901..60b4170 100644 --- a/src/InboxIntel.Application/Abstractions/IServices.cs +++ b/src/InboxIntel.Application/Abstractions/IServices.cs @@ -82,6 +82,25 @@ public interface IAiProvider Task CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default); } +/// +/// Produces vector embeddings for text (the foundation for semantic search, near-duplicate +/// detection, and "find similar"). Kept separate from because +/// embeddings are a distinct capability with their own model. The Null implementation returns +/// an empty vector and = false, so callers detect unavailability and +/// fall back to lexical search — AI is never required for core functionality. +/// +public interface IEmbeddingProvider +{ + /// False for the Null provider (AI off / no embedding model configured). + bool IsAvailable { get; } + + /// Embed a single text. Returns an empty array when unavailable. + Task EmbedAsync(string text, CancellationToken ct = default); + + /// Embed many texts, result aligned to input order. Empty list when unavailable. + Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct = default); +} + public enum ExportFormat { Pdf, Csv, Json } public interface IExportService diff --git a/src/InboxIntel.Infrastructure/Ai/AiProviders.cs b/src/InboxIntel.Infrastructure/Ai/AiProviders.cs index 8908ac2..b1b05ec 100644 --- a/src/InboxIntel.Infrastructure/Ai/AiProviders.cs +++ b/src/InboxIntel.Infrastructure/Ai/AiProviders.cs @@ -2,6 +2,7 @@ using InboxIntel.Application.Abstractions; using InboxIntel.Domain.Enums; using InboxIntel.Infrastructure.Configuration; using Microsoft.Extensions.Options; +using System.Linq; using System.Net.Http.Json; using System.Text.Json; @@ -15,6 +16,53 @@ public class NullAiProvider : IAiProvider => Task.FromResult(string.Empty); } +/// No-op embeddings used when AI is disabled. Returns empty vectors so callers fall +/// back to lexical search. +public class NullEmbeddingProvider : IEmbeddingProvider +{ + public bool IsAvailable => false; + public Task EmbedAsync(string text, CancellationToken ct = default) + => Task.FromResult(Array.Empty()); + public Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct = default) + => Task.FromResult>(Array.Empty()); +} + +/// Local embeddings via Ollama's /api/embeddings endpoint (e.g. nomic-embed-text). +public class OllamaEmbeddingProvider : IEmbeddingProvider +{ + private readonly HttpClient _http; + private readonly AiOptions _options; + + public OllamaEmbeddingProvider(IHttpClientFactory factory, IOptions options) + { + _options = options.Value; + _http = factory.CreateClient("ollama"); + _http.BaseAddress = new Uri(_options.OllamaBaseUrl); + } + + public bool IsAvailable => true; + + public async Task EmbedAsync(string text, CancellationToken ct = default) + { + var payload = new { model = _options.EmbeddingModel, prompt = text }; + var resp = await _http.PostAsJsonAsync("/api/embeddings", payload, ct); + resp.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty("embedding").EnumerateArray() + .Select(e => e.GetSingle()).ToArray(); + } + + // Ollama's /api/embeddings takes one prompt per call, so batch is a sequential loop. + // Kept behind the interface so a future batch endpoint is a drop-in swap. + public async Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct = default) + { + var results = new List(texts.Count); + foreach (var t in texts) + results.Add(await EmbedAsync(t, ct)); + return results; + } +} + /// Local LLM via Ollama's /api/chat endpoint. public class OllamaProvider : IAiProvider { diff --git a/src/InboxIntel.Infrastructure/Configuration/Options.cs b/src/InboxIntel.Infrastructure/Configuration/Options.cs index 77786e2..3d155e9 100644 --- a/src/InboxIntel.Infrastructure/Configuration/Options.cs +++ b/src/InboxIntel.Infrastructure/Configuration/Options.cs @@ -43,6 +43,8 @@ public class AiOptions // Ollama (local) public string OllamaBaseUrl { get; set; } = "http://localhost:11434"; public string OllamaModel { get; set; } = "llama3.1"; + // Embedding model for semantic search (per docs/discovery/06). Small, always-on when local. + public string EmbeddingModel { get; set; } = "nomic-embed-text"; // OpenAI (cloud, optional) public string OpenAiApiKey { get; set; } = string.Empty; diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs index 6d5c30f..69e4338 100644 --- a/src/InboxIntel.Infrastructure/DependencyInjection.cs +++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs @@ -67,13 +67,23 @@ public static class DependencyInjection services.AddHttpClient("ollama"); services.AddHttpClient("openai"); - // AI provider selected by configured mode. + // AI provider selected by configured mode. Embeddings come from Ollama when local, + // otherwise the Null provider (empty vectors) so semantic features degrade to lexical. var aiMode = config.GetSection(AiOptions.SectionName).GetValue("Mode"); switch (aiMode) { - case AiProviderMode.LocalOllama: services.AddScoped(); break; - case AiProviderMode.CloudOpenAi: services.AddScoped(); break; - default: services.AddScoped(); break; + case AiProviderMode.LocalOllama: + services.AddScoped(); + services.AddScoped(); + break; + case AiProviderMode.CloudOpenAi: + services.AddScoped(); + services.AddScoped(); // OpenAI embeddings: future + break; + default: + services.AddScoped(); + services.AddScoped(); + break; } services.AddScoped(); diff --git a/tests/InboxIntel.UnitTests/EmbeddingProviderTests.cs b/tests/InboxIntel.UnitTests/EmbeddingProviderTests.cs new file mode 100644 index 0000000..d4c7f3c --- /dev/null +++ b/tests/InboxIntel.UnitTests/EmbeddingProviderTests.cs @@ -0,0 +1,23 @@ +using FluentAssertions; +using InboxIntel.Infrastructure.Ai; +using Xunit; + +namespace InboxIntel.UnitTests; + +/// +/// The Null embedding provider is the AI-off / no-model path. Semantic features must be able +/// to detect unavailability (IsAvailable=false) and fall back to lexical search — this locks +/// that contract so "AI is never required" can't silently regress. +/// +public class EmbeddingProviderTests +{ + [Fact] + public async Task Null_provider_reports_unavailable_and_returns_empty_vectors() + { + var sut = new NullEmbeddingProvider(); + + sut.IsAvailable.Should().BeFalse(); + (await sut.EmbedAsync("anything")).Should().BeEmpty(); + (await sut.EmbedBatchAsync(new[] { "a", "b" })).Should().BeEmpty(); + } +}