C2 — POST /api/Resource/backfill-storage, SuperAdmin, dryRun à true par
défaut : la migration se joue sur une base vide, ce backfill sur des lignes
de production. StoragePath par ResourceStorage.PathFor, SizeBytes par HEAD.
La méthode annoncée au plan — « SizeBytes par listing du bucket Firebase » —
était inapplicable : le serveur n'avait aucun client de stockage. Le sondage
passe donc par HEAD sur l'URL publique, comme le fait déjà la migration, et
le sondeur est extrait plutôt que recopié (Helpers/ResourceSizeProbe,
consommé par MigrationController et par le backfill). Même raisonnement que
pour ResourceStorage : deux copies auraient divergé sur ce qui compte, le
sort réservé aux échecs.
L'extraction a bouché un trou que personne ne cherchait. L'original ne notait
l'échec que dans son catch, or un HEAD sur un blob absent ne lève pas : il
répond 404, sans Content-Length. Ces ressources arrivaient à 0 octet sans
figurer dans le rapport — invisibles au quota et invisibles au diagnostic,
exactement ce que le commentaire d'origine voulait empêcher.
Le « 37 lignes sur 45 » du plan n'étant pas vérifiable, le backfill rend son
propre inventaire : Orphans (aucune URL, blob peut-être jamais téléversé) et
Unsized (URL présente, bucket muet) restent séparés, ce sont deux causes
distinctes.
C3 — pré-vol du quota sur les deux chemins de création, suppression du blob
à Delete, angle mort d'Update tranché.
Deux défauts trouvés en câblant, qui n'étaient documentés nulle part :
- Le pré-vol existait déjà à moitié. Upload (multipart) contrôlait et
renvoyait 413, Create (JSON) ne contrôlait rien — or c'est le chemin
qu'emprunte manager-app, qui crée la ligne puis téléverse.
- Les deux lectures du quota divergeaient. Upload lisait le quota du plan,
GetQuota celui de l'instance avec le plan en repli. Une instance à quota
surchargé — le mécanisme même de l'add-on — affichait un chiffre à l'écran
et se faisait bloquer sur un autre. Helpers/StorageQuota devient la seule
source de vérité pour les deux.
Delete supprime le blob AVANT la ligne et renvoie 502 en conservant la ligne
si le bucket échoue. manager-app faisait l'inverse en avalant l'échec dans un
print : la ligne disparaissait, le blob restait, et n'ayant plus de ligne il
devenait invisible au quota tout en restant facturé. Une ressource encore
listée se rattrape ; un blob que plus aucune ligne ne désigne, non.
L'angle mort laissé ouvert par C1 était une fausse crainte : PathFor ne
construit qu'un pictures/{instanceId}/{resourceId}, le type n'entre pas dans
le chemin, il décide seulement s'il y en a un. Recalculer ne peut donc pas
pointer ailleurs, et Update rejoue Apply.
Aucun secret nouveau : FirebaseAdmin était déjà référencé pour les
notifications push et Startup charge déjà un service account, donc
Google.Cloud.Storage.V1 réutilise le même GoogleCredential. Seule s'ajoute la
clé Firebase:StorageBucket, vide par défaut — à renseigner en prod (I9),
sans quoi Delete ne supprime rien et ne prétend pas le contraire.
dotnet build vert, dotnet test 163/163 (148 au départ, +7 pour C2, +8 pour C3).
Contient aussi le correctif d'indexation préparé en parallèle : un job
Hangfire par section dans BackfillInstanceAsync au lieu d'une boucle, et un
backoff sur 429/503 dans GoogleEmbeddingService.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
11 KiB
C#
241 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;
|
|
private readonly IBackgroundJobClient _jobs;
|
|
|
|
public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore,
|
|
IConfiguration configuration, ILogger<IngestionService> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<int> 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<IIngestionService>(s => s.IngestSectionAsync(id));
|
|
|
|
_logger.LogInformation("Backfill instance {InstanceId} : {Count} sections mises en file",
|
|
instanceId, sectionIds.Count);
|
|
return sectionIds.Count;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|