Git workflow, environments & CI/CD pipeline (#1) #6
@@ -82,6 +82,25 @@ public interface IAiProvider
|
||||
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 interface IExportService
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public class OllamaProvider : IAiProvider
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AiProviderMode>("Mode");
|
||||
switch (aiMode)
|
||||
{
|
||||
case AiProviderMode.LocalOllama: services.AddScoped<IAiProvider, OllamaProvider>(); break;
|
||||
case AiProviderMode.CloudOpenAi: services.AddScoped<IAiProvider, OpenAiProvider>(); break;
|
||||
default: services.AddScoped<IAiProvider, NullAiProvider>(); break;
|
||||
case AiProviderMode.LocalOllama:
|
||||
services.AddScoped<IAiProvider, OllamaProvider>();
|
||||
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>();
|
||||
|
||||
|
||||
@@ -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