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>
237 lines
11 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|