d78fe601ff
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 26s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 16s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 53s
134 lines
5.2 KiB
C#
134 lines
5.2 KiB
C#
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;
|
|
|
|
namespace InboxIntel.Infrastructure.Ai;
|
|
|
|
/// <summary>No-op provider used when AI is disabled. Returns empty completions.</summary>
|
|
public class NullAiProvider : IAiProvider
|
|
{
|
|
public AiProviderMode Mode => AiProviderMode.Disabled;
|
|
public Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
|
|
=> 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
|
|
{
|
|
private readonly HttpClient _http;
|
|
private readonly AiOptions _options;
|
|
|
|
public OllamaProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
|
|
{
|
|
_options = options.Value;
|
|
_http = factory.CreateClient("ollama");
|
|
_http.BaseAddress = new Uri(_options.OllamaBaseUrl);
|
|
}
|
|
|
|
public AiProviderMode Mode => AiProviderMode.LocalOllama;
|
|
|
|
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
|
|
{
|
|
var payload = new
|
|
{
|
|
model = _options.OllamaModel,
|
|
stream = false,
|
|
messages = new[]
|
|
{
|
|
new { role = "system", content = systemPrompt },
|
|
new { role = "user", content = userPrompt }
|
|
}
|
|
};
|
|
var resp = await _http.PostAsJsonAsync("/api/chat", payload, ct);
|
|
resp.EnsureSuccessStatusCode();
|
|
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
|
return doc.RootElement.GetProperty("message").GetProperty("content").GetString() ?? string.Empty;
|
|
}
|
|
}
|
|
|
|
/// <summary>Cloud LLM via the OpenAI Chat Completions API (optional).</summary>
|
|
public class OpenAiProvider : IAiProvider
|
|
{
|
|
private readonly HttpClient _http;
|
|
private readonly AiOptions _options;
|
|
|
|
public OpenAiProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
|
|
{
|
|
_options = options.Value;
|
|
_http = factory.CreateClient("openai");
|
|
_http.BaseAddress = new Uri("https://api.openai.com");
|
|
_http.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _options.OpenAiApiKey);
|
|
}
|
|
|
|
public AiProviderMode Mode => AiProviderMode.CloudOpenAi;
|
|
|
|
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
|
|
{
|
|
var payload = new
|
|
{
|
|
model = _options.OpenAiModel,
|
|
messages = new[]
|
|
{
|
|
new { role = "system", content = systemPrompt },
|
|
new { role = "user", content = userPrompt }
|
|
}
|
|
};
|
|
var resp = await _http.PostAsJsonAsync("/v1/chat/completions", payload, ct);
|
|
resp.EnsureSuccessStatusCode();
|
|
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
|
return doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? string.Empty;
|
|
}
|
|
}
|