Thomas Fransolet 18e4240f0f RAG: pipeline d'ingestion, endpoints du guide IA, journalisation RGPD
Ingestion et indexation
- IIngestionService/IngestionService : chargement des collections filles par
  sous-type, un jeu de morceaux par langue, ChunkIndex continu.
- SectionIndexingInterceptor retenu comme unique déclencheur : les 5
  sous-contrôleurs totalisaient 30 SaveChanges et 0 Enqueue, donc ajouter des
  points d'intérêt à une carte ne réindexait rien.
- HTML retiré avant l'embedding et lignes trop longues recoupées : sans cela un
  article dépassait l'entrée max du modèle et emportait son lot de 50 morceaux.
- Gabarits de LanguageInit filtrés, DistinctBy(Text) avant le Take : ils
  occupaient les cinq premiers résultats d'une recherche en néerlandais.

Endpoints du guide IA
- GET /api/Ai/knowledge/{id} : agrégats sur ContentEmbedding, donc sur ce qui
  est réellement indexé — compter les sections publiées serait plus flatteur et faux.
- GET /api/Ai/insights/{id} : miroir de GuideIaInsights côté manager-app, c'est
  l'écran qui a fixé la forme pour que le job de thèmes la remplisse.

RGPD
- VisitorQuestion journalisée dans AiController.Chat. HasAnswer se déduit des
  sources du retrieval, pas du texte : un repli poli ressemble à une réponse.
  L'écriture n'échoue jamais la réponse au visiteur.
- VisitorQuestionPurgeService, 90 jours, actif sans condition de configuration :
  une durée écrite dans les CGU n'est pas un réglage commercial.

Corrections
- Updateinstance ne recopiait pas les quotas du nouveau plan.
- CheckQuota ne bloquait ni ne comptait à quota 0 — IA gratuite non comptée.
- StoragePath et SizeBytes renseignés à Create, types URL exclus.

dotnet build 0 erreur, dotnet test 130/130.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:48:10 +02:00

237 lines
11 KiB
C#

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";
/// <summary>
/// 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.
/// </summary>
private const int MaxChunkChars = 1200;
private readonly MyInfoMateDbContext _db;
private readonly IVectorStoreService _vectorStore;
private readonly IConfiguration _configuration;
private readonly ILogger<IngestionService> _logger;
public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore,
IConfiguration configuration, ILogger<IngestionService> logger)
{
_db = db;
_vectorStore = vectorStore;
_configuration = configuration;
_logger = logger;
}
public Task IngestSectionAsync(string sectionId) => IngestSectionCountingAsync(sectionId);
/// <summary>Même travail, en rendant le nombre de morceaux — ce dont le backfill a besoin.</summary>
private async Task<int> IngestSectionCountingAsync(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 0;
}
// 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 0;
// 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 0;
}
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);
return chunks.Count;
}
public async Task<int> BackfillInstanceAsync(string instanceId)
{
var sectionIds = await _db.Sections
.Where(s => s.InstanceId == instanceId)
.Select(s => s.Id)
.ToListAsync();
var chunkCount = 0;
foreach (var id in sectionIds)
chunkCount += await IngestSectionCountingAsync(id);
_logger.LogInformation("Backfill instance {InstanceId} : {Count} sections, {ChunkCount} morceaux",
instanceId, sectionIds.Count, chunkCount);
return chunkCount;
}
/// <summary>
/// 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.
/// </summary>
private async Task<Section> LoadWithChildrenAsync(Section stub) => stub.Type switch
{
SectionType.Map => await _db.Sections.AsNoTracking().OfType<SectionMap>()
.Include(s => s.MapPoints)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
SectionType.Quiz => await _db.Sections.AsNoTracking().OfType<SectionQuiz>()
.Include(s => s.QuizQuestions)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
SectionType.Agenda => await _db.Sections.AsNoTracking().OfType<SectionAgenda>()
.Include(s => s.EventAgendas)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
SectionType.Event => await _db.Sections.AsNoTracking().OfType<SectionEvent>()
.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<SectionParcours>()
.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<ContentChunk> BuildChunks(Section section)
{
var languages = _configuration.GetSection("SupportedLanguages").Get<List<string>>()
?? new List<string>();
var chunks = new List<ContentChunk>();
// 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;
}
/// <summary>
/// Gabarits posés par <c>LanguageInit.Init</c> à la création d'une section : « FR - Title »,
/// « NL - Description »… Ils restent tant que le client n'a pas rempli la langue.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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())));
/// <summary>
/// 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.
/// </summary>
private static string StripHtml(string text) =>
WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " "));
/// <summary>
/// Découpe sur les sauts de ligne — c'est déjà la frontière posée par JoinText entre
/// deux champs.
/// </summary>
private static IEnumerable<string> 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();
}
/// <summary>
/// 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.
/// </summary>
private static IEnumerable<string> 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;
}
}
}