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;
}
}
}