feat(ai): IEmbeddingProvider foundation #18

Merged
cesnimda merged 1 commits from feature/ai-embeddings into develop 2026-07-02 02:05:57 +02:00
5 changed files with 106 additions and 4 deletions
@@ -82,6 +82,25 @@ public interface IAiProvider
Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default); Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default);
} }
/// <summary>
/// Produces vector embeddings for text (the foundation for semantic search, near-duplicate
/// detection, and "find similar"). Kept separate from <see cref="IAiProvider"/> because
/// embeddings are a distinct capability with their own model. The Null implementation returns
/// an empty vector and <see cref="IsAvailable"/> = false, so callers detect unavailability and
/// fall back to lexical search — AI is never required for core functionality.
/// </summary>
public interface IEmbeddingProvider
{
/// <summary>False for the Null provider (AI off / no embedding model configured).</summary>
bool IsAvailable { get; }
/// <summary>Embed a single text. Returns an empty array when unavailable.</summary>
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
/// <summary>Embed many texts, result aligned to input order. Empty list when unavailable.</summary>
Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default);
}
public enum ExportFormat { Pdf, Csv, Json } public enum ExportFormat { Pdf, Csv, Json }
public interface IExportService public interface IExportService
@@ -2,6 +2,7 @@ using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums; using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Configuration; using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Linq;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
@@ -15,6 +16,53 @@ public class NullAiProvider : IAiProvider
=> Task.FromResult(string.Empty); => Task.FromResult(string.Empty);
} }
/// <summary>No-op embeddings used when AI is disabled. Returns empty vectors so callers fall
/// back to lexical search.</summary>
public class NullEmbeddingProvider : IEmbeddingProvider
{
public bool IsAvailable => false;
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<float>());
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<float[]>>(Array.Empty<float[]>());
}
/// <summary>Local embeddings via Ollama's /api/embeddings endpoint (e.g. nomic-embed-text).</summary>
public class OllamaEmbeddingProvider : IEmbeddingProvider
{
private readonly HttpClient _http;
private readonly AiOptions _options;
public OllamaEmbeddingProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
{
_options = options.Value;
_http = factory.CreateClient("ollama");
_http.BaseAddress = new Uri(_options.OllamaBaseUrl);
}
public bool IsAvailable => true;
public async Task<float[]> 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<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
{
var results = new List<float[]>(texts.Count);
foreach (var t in texts)
results.Add(await EmbedAsync(t, ct));
return results;
}
}
/// <summary>Local LLM via Ollama's /api/chat endpoint.</summary> /// <summary>Local LLM via Ollama's /api/chat endpoint.</summary>
public class OllamaProvider : IAiProvider public class OllamaProvider : IAiProvider
{ {
@@ -43,6 +43,8 @@ public class AiOptions
// Ollama (local) // Ollama (local)
public string OllamaBaseUrl { get; set; } = "http://localhost:11434"; public string OllamaBaseUrl { get; set; } = "http://localhost:11434";
public string OllamaModel { get; set; } = "llama3.1"; 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) // OpenAI (cloud, optional)
public string OpenAiApiKey { get; set; } = string.Empty; public string OpenAiApiKey { get; set; } = string.Empty;
@@ -67,13 +67,23 @@ public static class DependencyInjection
services.AddHttpClient("ollama"); services.AddHttpClient("ollama");
services.AddHttpClient("openai"); 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<AiProviderMode>("Mode"); var aiMode = config.GetSection(AiOptions.SectionName).GetValue<AiProviderMode>("Mode");
switch (aiMode) switch (aiMode)
{ {
case AiProviderMode.LocalOllama: services.AddScoped<IAiProvider, OllamaProvider>(); break; case AiProviderMode.LocalOllama:
case AiProviderMode.CloudOpenAi: services.AddScoped<IAiProvider, OpenAiProvider>(); break; services.AddScoped<IAiProvider, OllamaProvider>();
default: services.AddScoped<IAiProvider, NullAiProvider>(); break; services.AddScoped<IEmbeddingProvider, OllamaEmbeddingProvider>();
break;
case AiProviderMode.CloudOpenAi:
services.AddScoped<IAiProvider, OpenAiProvider>();
services.AddScoped<IEmbeddingProvider, NullEmbeddingProvider>(); // OpenAI embeddings: future
break;
default:
services.AddScoped<IAiProvider, NullAiProvider>();
services.AddScoped<IEmbeddingProvider, NullEmbeddingProvider>();
break;
} }
services.AddScoped<IAiService, AiService>(); services.AddScoped<IAiService, AiService>();
@@ -0,0 +1,23 @@
using FluentAssertions;
using InboxIntel.Infrastructure.Ai;
using Xunit;
namespace InboxIntel.UnitTests;
/// <summary>
/// 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.
/// </summary>
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();
}
}