DOCS/v2/rag-indexing-trigger-decision.md
Thomas Fransolet a5a8ecdb20 Documentation interne MyInfoMate / Unov
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>
2026-08-11 11:17:01 +02:00

133 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `Enqueue` dans 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](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`** :
```csharp
// 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](offline-visit-plan.md) § « Pourquoi ça a rouillé ». On ne le rejoue pas.
## Effets de bord favorables
- **`AgendaSyncService` est couvert sans y penser.** Le job réécrit les `EventAgenda` puis appelle `SaveChanges` : 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.Enqueue` est une façade statique : sans `JobStorage.Current`, elle lève. Les tests d'`AiController`/`SectionController` tombaient en 500 et en `Conflict`. L'intercepteur n'est branché que dans `Startup`, jamais dans `DbContextFactory` (EF InMemory) — les tests l'ignorent.
- **Un seul endroit pour la garde par plan.** `AiTokensPerMonth > 0` est 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 de `Create`, `Update` et `Delete`
- `AgendaSyncService` : l'appel direct à `IngestSectionAsync` après son `SaveChanges`
**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 `Section` restent 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`, quand `AiTokensPerMonth` passe de 0 à une valeur ;
- **manuellement** via `POST /api/Ai/reindex/{instanceId}`, **réservé au SuperAdmin**, et un bouton correspondant dans l'écran Guide IA de `manager-app` visible 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.completed` ne touche ni `SubscriptionPlanId` ni `AiTokensPerMonth` : il bascule `IsTrialActive` et stocke l'id d'abonnement. L'attribution de plan se fait à la main dans `InstanceController`. 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.