using ManagerService.Data; using Microsoft.Extensions.Configuration; using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; namespace ManagerService.Services { /// /// Embeddings via l'endpoint OpenAI-compatible de Google — même hôte et même clé /// que le chat (voir Startup), donc aucune configuration supplémentaire. /// public class GoogleEmbeddingService : IEmbeddingService { private const string Model = "gemini-embedding-001"; private const string Endpoint = "https://generativelanguage.googleapis.com/v1beta/openai/embeddings"; /// /// L'API accepte des lots plus gros, mais un lot trop large fait échouer les ~150 morceaux /// d'un document d'un coup sur un seul dépassement de limite de jetons. /// private const int BatchSize = 50; private const int MaxThrottleRetries = 3; private readonly IHttpClientFactory _httpClientFactory; private readonly string _apiKey; public int Dimensions => ContentEmbedding.Dimensions; public GoogleEmbeddingService(IHttpClientFactory httpClientFactory, IConfiguration configuration) { _httpClientFactory = httpClientFactory; _apiKey = configuration["AI:ApiKey"]; } public async Task EmbedAsync(string text, CancellationToken cancellationToken = default) { var result = await EmbedBatchAsync(new[] { text }, cancellationToken); return result[0]; } public async Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken cancellationToken = default) { if (texts == null || texts.Count == 0) return Array.Empty(); var results = new List(texts.Count); for (var offset = 0; offset < texts.Count; offset += BatchSize) { var batch = texts.Skip(offset).Take(BatchSize).ToArray(); results.AddRange(await EmbedOneBatchAsync(batch, cancellationToken)); } return results; } private async Task> EmbedOneBatchAsync(string[] batch, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiKey); // dimensions est obligatoire : le modèle renvoie 3072 valeurs par défaut, // ce que la colonne vector(768) refuserait. var payload = new EmbeddingRequest { Model = Model, Input = batch, Dimensions = Dimensions }; HttpResponseMessage response; // Une limite de débit est passagère : la remonter ferait échouer le job Hangfire, // qui retenterait la section entière — donc ré-embedderait ses morceaux déjà faits. // Retry-After d'abord, exponentiel s'il est absent. for (var attempt = 0; ; attempt++) { response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken); var throttled = response.StatusCode == HttpStatusCode.TooManyRequests || response.StatusCode == HttpStatusCode.ServiceUnavailable; if (!throttled || attempt >= MaxThrottleRetries) break; var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt)); await Task.Delay(wait, cancellationToken); } if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(cancellationToken); throw new InvalidOperationException( $"Embedding API error ({(int)response.StatusCode}): {body}"); } var parsed = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (parsed?.Data == null || parsed.Data.Count != batch.Length) throw new InvalidOperationException( $"Embedding API returned {parsed?.Data?.Count ?? 0} vectors for {batch.Length} texts."); // Vérifié le 2026-08-09 : Google ne renvoie pas le champ "index" du contrat // OpenAI — chaque item ne porte que "embedding" et "object". Trier dessus // donnerait un tri sur des zéros. L'ordre du tableau suit celui des entrées. return parsed.Data .Select(d => Normalize(d.Embedding)) .ToList(); } /// /// Normalise le vecteur en norme L2. /// gemini-embedding-001 ne renvoie des vecteurs normalisés que sur sa dimension /// native (3072). Toute dimension réduite sort brute, et la distance cosinus /// de pgvector suppose des vecteurs normalisés — sans ça, le classement des /// résultats est faussé sans qu'aucune erreur ne soit levée. /// private float[] Normalize(float[] vector) { if (vector == null || vector.Length != Dimensions) throw new InvalidOperationException( $"Embedding API returned {vector?.Length ?? 0} dimensions, expected {Dimensions}."); double sumOfSquares = 0; foreach (var value in vector) sumOfSquares += (double)value * value; var norm = Math.Sqrt(sumOfSquares); if (norm == 0) return vector; var normalized = new float[vector.Length]; for (var i = 0; i < vector.Length; i++) normalized[i] = (float)(vector[i] / norm); return normalized; } private class EmbeddingRequest { [JsonPropertyName("model")] public string Model { get; set; } [JsonPropertyName("input")] public string[] Input { get; set; } [JsonPropertyName("dimensions")] public int Dimensions { get; set; } } private class EmbeddingResponse { [JsonPropertyName("data")] public List Data { get; set; } } private class EmbeddingData { [JsonPropertyName("embedding")] public float[] Embedding { get; set; } } } }