using Hangfire; using ManagerService.Data; using ManagerService.Data.SubSection; using ManagerService.DTOs; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Text.RegularExpressions; using System.Threading.Tasks; namespace ManagerService.Services { [Queue(IngestionService.QueueName)] // Le défaut de Hangfire est à 10 tentatives : sur un contenu que l'API d'embedding // refuse, ça fait dix appels facturés pour le même échec. [AutomaticRetry(Attempts = 2)] public class IngestionService : IIngestionService { public const string QueueName = "ingestion"; /// /// Un morceau doit tenir un sujet, pas une section entière : au-delà, le vecteur /// moyenne tout et ne ressort plus sur aucune question précise. /// private const int MaxChunkChars = 1200; private readonly MyInfoMateDbContext _db; private readonly IVectorStoreService _vectorStore; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly IBackgroundJobClient _jobs; public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore, IConfiguration configuration, ILogger logger, IBackgroundJobClient jobs) { _db = db; _vectorStore = vectorStore; _configuration = configuration; _logger = logger; _jobs = jobs; } public async Task IngestSectionAsync(string sectionId) { var stub = await _db.Sections.AsNoTracking().FirstOrDefaultAsync(s => s.Id == sectionId); // Section supprimée entre l'enqueue et l'exécution : il reste ses morceaux à retirer. if (stub == null) { await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section); return; } // Re-vérifié ici et pas seulement à l'enqueue : le plan a pu changer entre les deux, // et c'est ce contrôle-ci qui décide de l'appel facturé. var hasAi = await _db.Instances .Where(i => i.Id == stub.InstanceId) .Select(i => i.AiTokensPerMonth) .FirstOrDefaultAsync() > 0; // Sans IA on n'indexe plus, mais on ne purge pas : décidé le 2026-08-10. Ce qui est // déjà indexé ne coûte que quelques Mo, l'usage est bloqué en amont, et un incident // de paiement réglé le lendemain ne doit pas imposer de tout ré-embedder. // Voir DOCS/v2/rag-indexing-trigger-decision.md. if (!hasAi) return; // Une section désactivée doit disparaître des réponses du guide, pas seulement de l'app. if (!stub.IsActive) { await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section); return; } var section = await LoadWithChildrenAsync(stub); var chunks = BuildChunks(section); await _vectorStore.ReplaceAsync(section.InstanceId, section.ConfigurationId, section.Id, ContentSourceType.Section, chunks); _logger.LogInformation("Section {SectionId} indexée : {ChunkCount} morceaux", sectionId, chunks.Count); } /// /// Un job par section plutôt qu'une boucle : l'unité de reprise devient la section. /// En boucle, un échec d'embedding à la 280ᵉ section faisait retenter le job entier par /// Hangfire, donc ré-embedder les 279 déjà indexées — des appels facturés pour rien, et /// autant de chances de retomber sur la même limite de débit. /// public async Task BackfillInstanceAsync(string instanceId) { var sectionIds = await _db.Sections .Where(s => s.InstanceId == instanceId) .Select(s => s.Id) .ToListAsync(); foreach (var id in sectionIds) _jobs.Enqueue(s => s.IngestSectionAsync(id)); _logger.LogInformation("Backfill instance {InstanceId} : {Count} sections mises en file", instanceId, sectionIds.Count); return sectionIds.Count; } /// /// Les collections filles vivent dans des tables séparées : sans chargement explicite, /// GetEmbeddableText ne verrait ni les points d'une carte, ni les questions d'un quiz, /// ni les étapes d'un parcours — et le guide ignorerait l'essentiel du contenu. /// private async Task
LoadWithChildrenAsync(Section stub) => stub.Type switch { SectionType.Map => await _db.Sections.AsNoTracking().OfType() .Include(s => s.MapPoints) .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, SectionType.Quiz => await _db.Sections.AsNoTracking().OfType() .Include(s => s.QuizQuestions) .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, SectionType.Agenda => await _db.Sections.AsNoTracking().OfType() .Include(s => s.EventAgendas) .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, SectionType.Event => await _db.Sections.AsNoTracking().OfType() .Include(s => s.Programme).ThenInclude(b => b.MapAnnotations) .Include(s => s.GlobalMapAnnotations) .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, SectionType.Parcours => await _db.Sections.AsNoTracking().OfType() .Include(s => s.GuidedPaths).ThenInclude(p => p.Steps).ThenInclude(st => st.QuizQuestions) .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, // Les autres sous-types portent tout leur contenu dans leurs colonnes jsonb. _ => stub }; private List BuildChunks(Section section) { var languages = _configuration.GetSection("SupportedLanguages").Get>() ?? new List(); var chunks = new List(); // ChunkIndex court sur toute la section, langues confondues : la contrainte d'unicité // porte sur (ContentType, ContentId, ChunkIndex), un compteur remis à zéro par langue // ferait échouer l'insertion dès la deuxième. foreach (var language in languages) { var text = WithoutPlaceholders(StripHtml(section.GetEmbeddableText(language))); if (string.IsNullOrWhiteSpace(text)) continue; foreach (var piece in Split(text)) chunks.Add(new ContentChunk(piece, chunks.Count, null, language.ToUpperInvariant())); } return chunks; } /// /// Gabarits posés par LanguageInit.Init à la création d'une section : « FR - Title », /// « NL - Description »… Ils restent tant que le client n'a pas rempli la langue. /// /// /// Mesuré sur l'instance de démo le 2026-08-10 : sans ce filtre, les cinq premiers /// résultats d'une question en néerlandais étaient tous des « NL - Title NL - Description ». /// Le bonus de langue suffit à les faire passer devant du vrai contenu français — soit /// exactement le cas que la recherche cross-lingue devait servir. Une langue qui ne /// contient que des gabarits ne produit donc plus aucun morceau. /// private static readonly Regex PlaceholderLine = new(@"^[A-Za-z]{2}\s*-\s*(Title|Description|Content|Audio)$", RegexOptions.Compiled); private static string WithoutPlaceholders(string text) => string.Join("\n", text .Split('\n') .Where(line => !PlaceholderLine.IsMatch(line.Trim()))); /// /// Les champs riches sont saisis en HTML dans le back-office. Les balises se retrouvent /// à l'identique dans tous les contenus : elles tirent les vecteurs vers un fond commun /// et écrasent les écarts de score entre un morceau pertinent et un hors-sujet. /// Remplacées par une espace et non par rien, sinon deux paragraphes accolés fusionnent /// leurs mots — c'est ce qui distingue cette version de celle d'AssistantService, écrite /// pour de l'affichage. /// private static string StripHtml(string text) => WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " ")); /// /// Découpe sur les sauts de ligne — c'est déjà la frontière posée par JoinText entre /// deux champs. /// private static IEnumerable Split(string text) { var current = new System.Text.StringBuilder(); foreach (var line in text.Split('\n').SelectMany(SplitLongLine)) { if (current.Length > 0 && current.Length + line.Length + 1 > MaxChunkChars) { yield return current.ToString().Trim(); current.Clear(); } current.Append(line).Append('\n'); } if (current.Length > 0) yield return current.ToString().Trim(); } /// /// Le contenu d'un article est du HTML sans saut de ligne : une fois les balises retirées, /// il forme une seule ligne de toute la longueur du texte. Sans cette coupe il part d'un /// bloc à l'embedding, dépasse l'entrée maximale du modèle, et l'échec emporte les /// 49 autres morceaux de son lot — la section la plus riche du CMS ne s'indexe jamais. /// La coupe cherche une fin de phrase avant de se rabattre sur une espace : trancher au /// caractère près séparerait une phrase de son sujet. /// private static IEnumerable SplitLongLine(string line) { while (line.Length > MaxChunkChars) { var cut = line.LastIndexOf(". ", MaxChunkChars, StringComparison.Ordinal); if (cut <= 0) cut = line.LastIndexOf(' ', MaxChunkChars); if (cut <= 0) cut = MaxChunkChars - 1; yield return line.Substring(0, cut + 1).Trim(); line = line.Substring(cut + 1).TrimStart(); } yield return line; } } }