feat(ai): IEmbeddingProvider foundation #18
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user