Import initial de la documentation : statut, roadmap, plans V1/V2, specs verticales (creche, sport), audits securite, plan de test, analyse concurrentielle et maquettes de design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
Déclenchement de la ré-indexation RAG — décision du 2026-08-10
Pourquoi la ré-indexation part d'un intercepteur EF et non d'un
Enqueuedans chaque contrôleur. Les deux versions ont réellement coexisté dans le dépôt une demi-journée : ceci évite de rejouer l'arbitrage.
La question
Quand le contenu d'une section change, il faut recalculer ses embeddings. Deux endroits possibles pour déclencher le job Hangfire :
| Enqueue par contrôleur | Intercepteur SaveChanges |
|
|---|---|---|
| Où | BackgroundJob.Enqueue après chaque SaveChanges() d'un endpoint |
SectionIndexingInterceptor, branché une fois dans Startup |
| Ce que dit le plan | c'est la version d'origine de rag-pgvector-integration-plan.md §3 | — |
Ce qui a tranché : les sous-contrôleurs
Le contenu d'une section ne se modifie presque jamais depuis SectionController. Il se modifie depuis les sous-contrôleurs, qui enregistrent une entité fille sans jamais toucher la ligne Section :
// SectionMapController:148 — ajout d'un point d'intérêt
existingSection.MapPoints.Add(geoPoint);
_myInfoMateDbContext.SaveChanges(); // la ligne Section reste Unchanged
Relevé le 2026-08-10, avec l'enqueue par contrôleur en place :
| Contrôleur | SaveChanges |
Enqueue |
|---|---|---|
SectionQuizController |
4 | 0 |
SectionMapController |
10 | 0 |
SectionEventController |
7 | 0 |
SectionParcoursController |
6 | 0 |
SectionAgendaController |
3 | 0 |
Conséquence concrète : un client crée une section Carte (indexée, vide), puis y ajoute ses 40 points d'intérêt → aucune ré-indexation. Le guide connaît le titre de la carte et rien d'autre. Idem pour les questions de quiz, les étapes de parcours, les blocs de programme, les événements d'agenda.
C'est exactement le contenu que les 13 GetEmbeddableText() viennent d'apprendre à extraire. L'extraction sans le déclenchement ne sert à rien.
Rattraper ça par contrôleur imposait ~30 Enqueue, et de compter sur chaque endpoint futur pour ne pas l'oublier. C'est le motif exact qui a laissé pourrir le switch de collecte des ressources — voir offline-visit-plan.md § « Pourquoi ça a rouillé ». On ne le rejoue pas.
Effets de bord favorables
AgendaSyncServiceest couvert sans y penser. Le job réécrit lesEventAgendapuis appelleSaveChanges: l'intercepteur déclenche donc la ré-indexation après la synchro, sur les dates fraîches. C'était le point 3 des ajouts au lot 3, il disparaît.- Les tests ne cassent pas.
BackgroundJob.Enqueueest une façade statique : sansJobStorage.Current, elle lève. Les tests d'AiController/SectionControllertombaient en 500 et enConflict. L'intercepteur n'est branché que dansStartup, jamais dansDbContextFactory(EF InMemory) — les tests l'ignorent. - Un seul endroit pour la garde par plan.
AiTokensPerMonth > 0est vérifié une fois avant l'enqueue, au lieu d'être à répéter sur chaque site.
Ce qu'il faut savoir en le maintenant
Il existe une table de résolution enfant → section, dans Collect(). C'est le point qui peut vieillir : un nouveau type d'entité fille rattachée à une section doit y être ajouté.
| Entité fille | Résolution |
|---|---|
GeoPoint |
SectionMapId ?? SectionEventId |
EventAgenda |
SectionAgendaId ?? SectionEventId |
MapAnnotation |
SectionEventId (null pour une annotation de bloc — c'est alors le ProgrammeBlock qui porte le lien) |
ProgrammeBlock |
clé étrangère fantôme SectionEventId, lue via entry.Property(...) |
GuidedPath |
SectionParcoursId ?? SectionEventId |
GuidedStep |
GuidedPathId → une requête vers GuidedPath |
QuizQuestion |
SectionQuizId, ou GuidedStepId → GuidedPath → section |
Les clés étrangères sont relevées dans SavingChanges, pas après : une entité supprimée n'est plus interrogeable une fois l'enregistrement passé.
Filtre sur le réordonnancement
Changer l'Order d'une section ne change pas un mot du texte indexé. Sans filtre, un glisser-déposer dans le manager fait payer un embedding par section déplacée — et plusieurs contrôleurs enregistrent dans une boucle, un SaveChanges par élément (SectionQuizController:246).
NonIndexedSectionProperties = { Order, DateUpdate }. Toute autre propriété modifiée déclenche la ré-indexation : le repli est volontairement prudent, un champ ajouté plus tard sera réindexé pour rien plutôt qu'oublié en silence.
Pas de boucle infinie
VectorStoreService.ReplaceAsync appelle SaveChanges pour écrire les ContentEmbedding. L'intercepteur se déclenche, ne trouve aucune Section dans le ChangeTracker, et sort avant toute requête.
Ce qui a été supprimé en appliquant la décision
SectionController: les enqueues d'ingestion deCreate,UpdateetDeleteAgendaSyncService: l'appel direct àIngestSectionAsyncaprès sonSaveChanges
Conservé : SectionController enqueue toujours AgendaSyncService.SyncSectionAsync quand on met à jour un agenda en ligne. Ce n'est pas de l'indexation — c'est le rapatriement des événements distants, et c'est lui qui garantit que la ré-indexation qui suit porte sur des dates à jour.
Downgrade : on conserve — tranché le 2026-08-10
Quand une instance perd l'IA (downgrade, essai expiré, paiement en échec), IngestSectionAsync sort sans rien faire. Il ne purge plus.
Pourquoi conserver :
- un vecteur pèse 768 × 4 octets ≈ 3 Ko ; un lieu entier tient en quelques Mo — le stockage n'est pas un argument ;
- l'usage est déjà bloqué en amont, dans
AiController.CheckQuota: les embeddings dormants ne fuient nulle part ; - purger ferait payer une réindexation complète pour un incident de paiement réglé le lendemain, avec un guide amnésique entre-temps.
La purge reste déclenchée par les deux cas qui la justifient vraiment : section supprimée, et section désactivée (IsActive = false — une section retirée de l'app ne doit plus alimenter les réponses).
À faire un jour : purger à la suppression de l'instance. Aujourd'hui rien ne le fait — mais les lignes
Sectionrestent elles aussi, donc les embeddings ne sont pas une exposition supplémentaire.
Trou de facturation trouvé en passant — corrigé
CheckQuota ne bloquait que si quota > 0 && AiTokensThisMonth >= quota. Avec AiTokensPerMonth = 0, la condition est fausse : aucun blocage, aucun compteur.
Le piège vient d'une ambiguïté de convention dans le code :
| Champ | Que veut dire 0 |
|---|---|
StorageQuotaBytes (ResourceController) |
illimité — if (storageQuota > 0) |
AiTokensPerMonth |
pas d'IA — c'est la valeur de plan-starter, et la garde d'indexation lit > 0 |
IsAssistant est un drapeau posé à la main sur l'instance (InstanceController:197), indépendant du plan. Une instance plan-starter avec IsAssistant = true obtenait donc de l'IA gratuite et non comptée.
Et ce n'est pas théorique : MigrationController ne reprend pas SubscriptionPlanId (écart c du §1quinquies), donc toute instance migrée depuis Mongo arrive à AiTokensPerMonth = 0. Deux symptômes simultanés : rien ne s'indexe, et l'IA consomme sans compteur.
Corrigé : quota <= 0 → 403 « L'assistant IA n'est pas inclus dans ce plan », avant tout appel au modèle. Les 3 tests d'AiController qui passaient sans quota s'appuyaient sur l'ancienne sémantique — ils documentaient le trou. Fixtures corrigées, et Chat_PlanWithoutAi_ReturnsForbiddenWithoutCallingAssistantService ajouté pour verrouiller la règle.
Le rattrapage à l'upgrade — livré le 2026-08-10
Tant qu'une instance n'a pas droit à l'IA, la garde écarte chaque save : rien n'est indexé. Le jour de l'upgrade, le client a un guide et un index vide — SearchKnowledge ne rend rien, et la règle de prompt fait répondre « cette information n'est pas disponible » à tout. Rien ne se répare seul : la ré-indexation ne part que d'un save, il faudrait rouvrir chaque section une par une.
BackfillInstanceAsync est désormais appelé :
- automatiquement dans
InstanceController.Updateinstance, quandAiTokensPerMonthpasse de 0 à une valeur ; - manuellement via
POST /api/Ai/reindex/{instanceId}, réservé au SuperAdmin, et un bouton correspondant dans l'écran Guide IA demanager-appvisible pour ce seul rôle.
Pourquoi SuperAdmin seulement : c'est un outil de réparation, pas une fonctionnalité. Exposé au client, il serait cliqué à chaque réponse décevante du guide — alors qu'une mauvaise réponse vient presque toujours d'un contenu trop maigre, pas d'un index périmé. Chaque clic recoûte un embedding par morceau de toute l'instance, sans rien améliorer, et c'est un moyen trivial de brûler le budget IA.
L'endpoint rend { jobId, sectionsQueued } en 202. Le nombre de morceaux n'est connu qu'à l'exécution : il part dans les logs et dans /hangfire. sectionsQueued dit au moins si la relance avait la moindre matière.
Le webhook Stripe n'est pas un point d'accroche — contrairement à ce que supposait le plan.
checkout.session.completedne touche niSubscriptionPlanIdniAiTokensPerMonth: il basculeIsTrialActiveet stocke l'id d'abonnement. L'attribution de plan se fait à la main dansInstanceController. Si le webhook se met un jour à changer le plan, il devra reprendre la même garde 0 → >0.
Deux défauts trouvés en branchant ça
Updateinstance ne recopiait pas les quotas du nouveau plan. CreateInstance le faisait, pas lui. Passer un client de Starter à Premium changeait SubscriptionPlanId et rien d'autre : il gardait 1 Go de stockage et 0 jeton IA. L'endpoint /quota masquait la moitié du problème en retombant sur le plan à la lecture, mais AiController lit instance.AiTokensPerMonth — le client payait un plan avec IA et restait sans IA. Extrait dans ApplyPlanQuotas, appelé par les deux chemins.
IBackgroundJobClient injecté au lieu de la façade statique BackgroundJob. Celle-ci lève sans JobStorage.Current, donc dans tout test qui traverse l'endpoint — c'est ce qui avait mis 2 tests au rouge le matin même. Injecté, il se moque.