24 KiB
1quater. Chantier « Guide IA V1 » — ordre d'exécution (arrêté le 2026-08-07)
← Section §1quater du tableau de bord : STATUS.md
Cette section ne décrit rien de neuf : elle donne l'ordre dans lequel exécuter ce que trois plans détaillent déjà, parce que le chantier les traverse tous les trois et qu'aucun ne porte la vue d'ensemble.
Décision : tout est fait d'un coup, sans mise en prod intermédiaire. Rien ne part en prod avant que le backlog (kanban + ce fichier) soit terminé — voir §1ter, la base Postgres est vide et c'est une fenêtre à ne pas refermer.
✅ Lot 1 — Rendre vrai ce qui est déjà livré · fait le 2026-08-07
→ v2/guide-ia-screen-plan.md § « Configuration branchée sur AssistantService »
BuildGuideIdentity() et BuildFallbackInstruction() dans AssistantService, consommés par les 4 blocs de prompt — le relevé initial n'en annonçait que deux, il en existe quatre (scope configuration / scope instance × vocal / texte). Ton codé en dur retiré, seconde politique de repli de la ligne 550 réconciliée, règle hors-sujet ajoutée aux deux variantes texte qui n'en avaient aucune. Les 4 new HttpClient() des outils d'agenda passent par IHttpClientFactory (§3, priorité haute).
dotnet build 0 erreur · dotnet test 124/124.
⚠️ Pas encore vu tourner : aucun échange réel joué contre un guide configuré. À couvrir au moment du plan de test.
✅ Lot 2 — Le schéma · livré le 2026-08-09
→ v2/rag-pgvector-integration-plan.md · v2/media-storage-plan.md
| # §1ter | Livré | Vérifié en base |
|---|---|---|
| 1 | Image postgis + pgvector — Deployment/Dockerfile.postgres, base épinglée par digest |
postgis 3.4.3 + vector 0.8.6 |
| 2 | Table ContentEmbedding + 3 index (dont HNSW vector_cosine_ops) |
migration AddContentEmbedding |
| 3 | IEmbeddingService + GoogleEmbeddingService (batch par 50) |
768 validé contre l'API réelle, insertion + recherche jouées en base |
| 4 | Colonnes Resource (7) + Word/PowerPoint/Text en fin d'enum |
migration AddResourceStorageAndAiColumns |
| 7 | Table VisitorQuestion + ConversationId sur AiChatRequest |
migration AddVisitorQuestion |
dotnet build 0 erreur · dotnet test 124/124 · 62 migrations, 26 tables · données intactes (52 sections, 45 ressources).
Ce que l'appel réel à l'API a appris (2026-08-09) — trois choses qu'aucun plan ne disait :
gemini-embedding-001renvoie 3072 dimensions par défaut. Le paramètredimensions: 768est obligatoire dans la requête, sinon la colonnevector(768)rejette l'insertion.- Les vecteurs réduits ne sont pas normalisés. Norme L2 mesurée : 0,578, pas 1. La distance cosinus de pgvector suppose des vecteurs normalisés — sans normalisation côté client, le classement des résultats serait faussé sans qu'aucune erreur ne soit levée.
GoogleEmbeddingService.Normalize()s'en charge. - Google ne renvoie pas le champ
indexdu contrat OpenAI (chaque item ne porte queembeddingetobject). UnOrderBy(d => d.Index)trierait sur des zéros : retiré, l'ordre du tableau fait foi.
Recherche cross-lingue vérifiée en base, question posée en néerlandais sur du contenu FR/NL :
| Contenu | Similarité |
|---|---|
| NL, même sujet | 0,8145 |
| FR, même sujet | 0,7982 |
| FR, hors sujet | 0,4818 |
Le pari « pas de filtre de langue » tient : le FR pertinent bat largement le FR hors-sujet. Et l'écart NL/FR n'est que de 0,016 — trop faible pour trier tout seul, ce qui confirme la nécessité du bonus de score par langue plutôt que de s'en remettre à la similarité brute.
Vérifié aussi : la contrainte anti-doublon rejette bien un rejeu Hangfire, et un vecteur de 512 dimensions est refusé par la colonne.
Schéma ContentEmbedding validé le 2026-08-09 — il diverge du plan écrit sur trois points, sur la base du code réel :
string InstanceIdet nonint VenueId: tous les Id du projet sont desstring(héritage ObjectId Mongo). Filtre dur — frontière entre deux clients.string? ConfigurationIdnullable, absent du plan :Resourcene porte pas deConfigurationId. Sert de bonus de score, jamais de filtre — décision produit : le guide connaît tout le lieu et priorise la visite en cours. Même patron que le multilingue.long Id: table technique à fort volume, jamais référencée ailleurs.
Relevé en chemin :
- ⚠️ La base locale avait une migration de retard.
AddGuideIaConfigToInstance(lot 1) n'était pas appliquée : les colonnesGuide*n'existaient pas, donc toute requête surInstancesaurait planté — l'API entière, dès le login. Appliqué le 2026-08-09. Le compilateur ne dit rien de l'état de la base. - ⚠️ Warning de collation sur la base locale (
glibc 2.36→2.31), corrigé parREINDEXpuisREFRESH COLLATION VERSION— dans cet ordre, le refresh seul masque le symptôme sans réparer les index. C'est la raison de l'épinglage par digest : un tag mobile rejouerait ça sur des données clients. SizeBytesexistait déjà surResource, contrairement à ce qu'annonçait le plan médias — mais jamais renseigné (= 0partout). Le backfill (point 5) reste entier.ResourceDTOvolontairement non modifié : exposer les nouvelles colonnes désynchroniseraitmanager_api_new, qui s'édite à la main. L'UI est V2.- pgvector 0.8.6 et non 0.5 : les iterative index scans existent (
hnsw.iterative_scan), vraie parade au post-filtrage HNSW là où le plan ne prévoyait que de monteref_search. À régler au lot 3.
⚠️ Dette ouverte — aucun test ne couvrira le RAG
Ajouter ContentEmbedding a cassé 116 des 124 tests d'un coup : ils tournent sur EF InMemory, qui ne connaît pas le type Vector et refuse de valider le modèle. Contournement appliqué — l'entité est exclue quand le provider n'est pas Npgsql (Database.IsNpgsql() dans OnModelCreating).
Les tests repassent, mais la conséquence est structurelle : vector store, recherche cosinus, index HNSW, contrainte anti-doublon Hangfire — rien de tout ça n'est testable tant que la suite ne tourne pas sur un vrai Postgres. C'est la dette « tests EF InMemory peu représentatifs » du §3, qui cesse d'être théorique : elle rend le cœur du lot 3 non couvert.
Piste : Testcontainers (Testcontainers.PostgreSql) sur l'image Dockerfile.postgres, au moins pour les tests du vector store. Pas chiffré, pas planifié.
Lot 3 — Le RAG · 1 à 2 semaines
→ v2/rag-pgvector-integration-plan.md § « Ensuite — incrément indépendant, à froid »
IVectorStoreService (delete-then-insert transactionnel) · jobs Hangfire sur les saves de SectionController, queue dédiée · respect d'IsActive avec purge des embeddings à la désactivation (non négociable) · endpoint /ask, recherche cross-lingue sans filtre de langue avec bonus de score, réglage du post-filtrage HNSW, sources renvoyées dans tous les cas.
Le point de charge caché du chantier.
GetEmbeddableText()est à écrire pour chacun des 13 sous-types de section — ingrat, sans difficulté technique, mais c'est lui qui détermine la qualité des réponses : ce qu'on n'extrait pas, le guide ne le connaîtra jamais.Il se double de
GetReferencedResourceIds(), réclamé par v2/offline-visit-plan.md — mêmes sous-types, mêmes structures parcourues. À écrire dans la même passe. Les faire séparément, c'est payer deux fois.✅ La passe commune a bien eu lieu — vérifié le 2026-08-11 :
GetReferencedResourceIds()existe dans les 13 sous-types deData/SubSection/. Mais il n'est appelé nulle part : le switch de collecte deConfigurationController.Exportest toujours commenté en bloc (~180 lignes mortes). Le bug offline nº1 coûte donc le branchement d'une méthode existante, pas une passe sur 13 sous-types — il descend en coût, pas en priorité.
✅ Pipeline d'ingestion livré le 2026-08-10.
IIngestionService/IngestionService: chargement des collections filles par sous-type (sansInclude,GetEmbeddableTextrendait un texte amputé de ses événements, étapes et questions sans rien signaler), un jeu de morceaux par langue renseignée,ChunkIndexcontinu toutes langues confondues pour respecter la contrainte d'unicité. Branché sur les trois chemins deSectionController(création, mise à jour, suppression), surAgendaSyncService.SyncSectionAsync— après sonSaveChanges, sinon on indexerait les anciennes dates — et servi par un serveur Hangfire séparé (ingestion, 2 workers) pour que les extractions ne prennent pas les workers de la file générale.✅ Garde par plan.
AiTokensPerMonth > 0vérifié dans le job, pas seulement à l'enqueue : le plan peut changer entre les deux, et c'est ce contrôle-là qui décide de l'appel facturé.✅ Rattrapage à l'upgrade branché le 2026-08-10.
BackfillInstanceAsyncpart automatiquement depuisInstanceController.UpdateinstancequandAiTokensPerMonthpasse de 0 à une valeur, et manuellement viaPOST /api/Ai/reindex/{instanceId}— SuperAdmin uniquement, avec le bouton correspondant dans l'écran Guide IA demanager-app. Réservé au support, volontairement : exposé au client, il serait cliqué à chaque réponse décevante du guide, pour un coût d'embedding complet et sans rien améliorer.⚠️ 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 est manuelle. Le jour où le webhook changera le plan, il devra reprendre la même garde 0 → >0.🐛
Updateinstancene recopiait pas les quotas du nouveau plan —CreateInstancele faisait, pas lui. Passer un client de Starter à Premium changeaitSubscriptionPlanIdet rien d'autre : 1 Go de stockage et 0 jeton IA conservés. L'endpoint/quotamasquait la moitié du problème en retombant sur le plan à la lecture, maisAiControllerlitinstance.AiTokensPerMonth— le client payait un plan avec IA et restait sans IA. Extrait dansApplyPlanQuotas, appelé par les deux chemins, avec test.✅
SearchKnowledgeajouté aux outils d'AssistantService— pas d'endpoint/askséparé, qui aurait donné deux assistants concurrents : l'un sachant interroger l'agenda mais ignorant le contenu, l'autre l'inverse. Rend une phrase explicite quand la recherche ne trouve rien, sinon le modèle comble le silence avec ses connaissances générales.✅ Deux angles morts du découpage corrigés le 2026-08-10. Ils frappaient le même contenu — l'article, le texte le plus riche du CMS :
- HTML retiré avant l'embedding (
StripHtml, remplacement par une espace +HtmlDecode). Les balises sont identiques dans tous les contenus : elles tiraient les vecteurs vers un fond commun et écrasaient les écarts de score, bien plus gênant que leur coût en jetons. Volontairement distinct duStripHtmld'AssistantService, qui remplace par""— correct pour de l'affichage, mais y accoler deux paragraphes fusionnerait leurs mots.- ligne plus longue que
MaxChunkCharsdécoupée (SplitLongLine, coupe à la dernière fin de phrase, repli sur l'espace). Le HTML ne contient pas de\n: une fois les balises retirées, un article formait une seule ligne de toute sa longueur, dépassait l'entrée maximale du modèle, et l'échec emportait les 49 autres morceaux de son lot d'embedding. Symptôme : « le guide ne connaît pas mes articles », visible nulle part ailleurs que dans le dashboard/hangfire.⚠️ Vérifier l'entrée maximale réelle de
gemini-embedding-001dans la doc Google au moment du test de charge —MaxChunkChars = 1200(~300 jetons) a de la marge, mais la valeur n'a pas été confirmée.✅ Downgrade tranché le 2026-08-10 : on conserve. Le code purgeait les embeddings dès que l'instance perdait l'IA. Rétabli sur la décision du plan — quelques Mo de vecteurs ne pèsent rien, l'usage est bloqué en amont, et purger ferait payer une réindexation complète pour un incident de paiement réglé le lendemain. La purge reste sur les deux cas qui la justifient : section supprimée, section désactivée (
IsActive).🐛 Trou de facturation trouvé et corrigé le 2026-08-10.
CheckQuotane bloquait que siquota > 0 && AiTokensThisMonth >= quota: avecAiTokensPerMonth = 0, aucun blocage et aucun compteur. L'ambiguïté vient du code lui-même —0veut dire illimité pourStorageQuotaBytesmais pas d'IA pourAiTokensPerMonth. CommeIsAssistantest un drapeau manuel indépendant du plan (InstanceController:197), une instanceplan-starteravecIsAssistant = trueconsommait de l'IA gratuite et non comptée. Pas théorique :MigrationControllerne reprend pasSubscriptionPlanId(écart c du §1quinquies), donc toute instance migrée depuis Mongo arrive à 0 — rien ne s'indexe et l'IA tourne sans compteur. Corrigé en 403 avant tout appel au modèle, avec un test dédié.✅ Déclenchement unifié le 2026-08-10 → v2/rag-indexing-trigger-decision.md. Deux mécanismes concurrents coexistaient (un
Enqueuepar contrôleur et un intercepteur EF), ce qui doublait chaque enqueue et cassait 2 tests —BackgroundJob.Enqueueest statique et lève sansJobStorage.Current. L'enqueue par contrôleur seul ne pouvait pas marcher : mesuré, les 5 sous-contrôleurs totalisent 30SaveChangeset 0Enqueue, et ils enregistrent l'entité fille sans jamais toucher la ligneSection(SectionMapController:148). Un client ajoutant 40 points d'intérêt à une carte n'aurait rien réindexé — précisément le contenu que les 13GetEmbeddableText()savent extraire. Retenu :SectionIndexingInterceptorseul, avec sa table de résolution enfant → section et un filtre{Order, DateUpdate}qui évite de payer un embedding par section déplacée lors d'un réordonnancement.
✅ Première exécution réelle — 2026-08-10. Le pipeline a tourné contre la base locale et l'API Google, hors HTTP (harnais jetable, pas de contournement d'auth). 52 sections → 2150 morceaux en 53 s. L'index se remplit, la recherche cosinus répond, le parcours itératif HNSW ne bronche pas, et le bonus de langue fait son travail : question NL → morceaux NL en tête, avec du contenu d'une autre langue qui remonte quand même.
⚠️ Constat en passant, en données réelles : l'instance était sur
plan-standard(5 M jetons) avecAiTokensPerMonth = 0sur la ligneInstance. C'est le bugUpdateinstanceen vrai, pas en théorie — sans le correctif, la garde d'indexation aurait écarté les 52 sections en silence.Deux défauts que seule l'exécution pouvait montrer, corrigés :
- Les gabarits étaient indexés.
LanguageInit.Initpose « FR - Title », « NL - Description » à la création d'une section, et ils restent tant que la langue n'est pas remplie. Résultat mesuré : les cinq premiers résultats d'une question en néerlandais étaient desNL - Title NL - Description. Le bonus de langue suffit à faire passer un gabarit vide devant du vrai contenu français — soit exactement le cas que la recherche cross-lingue devait servir. Filtrés à l'indexation (WithoutPlaceholders) : une langue qui n'a que des gabarits ne produit plus aucun morceau. −255 morceaux sur 2405.- Le même texte occupait trois des cinq résultats, au même score : une description recopiée sur chacun des événements d'un agenda.
DistinctBy(Text)avant leTake(topK)— sinon c'est 60 % du contexte du modèle gâché.Après correction, les cinq premiers résultats FR et NL sont du contenu réel et distinct.
⚠️ Restent non vérifiés : l'entrée maximale réelle de
gemini-embedding-001(aucun morceau n'a atteint la limite sur ce jeu) et le comportement du post-filtrage HNSW à plusieurs instances — il n'y en a qu'une en base locale, ce qui est précisément le cas où le problème ne se voit pas.
Jalon démontrable en clientèle : à ce point le guide répond correctement sur le contenu CMS seul. Tout le reste est de l'amélioration.
Lot 4 — Le journal et le reste de l'écran · ~1 semaine
→ v2/guide-ia-screen-plan.md §7 et § « Ensuite »
Insertion VisitorQuestion dans AiController.Chat, à côté du RecordUsage existant · job de regroupement en thèmes · job de purge à 90 jours · onglet « Ce que demandent vos visiteurs » · aperçu de conversation (à faire converger avec le « Mode preview / cible Assistant-Persona » de todo-features.md, pas construire deux fois) · onglet « Vocal » dans les stats. Volet RGPD dans les CGU avant mise en service.
⚠️ Ce que les 4 lots ne couvrent PAS
Les lots 1 à 4 découpent le chantier Guide IA, pas tout le §1ter. Le lot 2 a pris le point 4 (colonnes Resource) parce que c'est du schéma partagé avec le RAG, mais les points 5, 6, 8 et 9 n'appartiennent à aucun lot — ils relèvent du chantier médias & stockage, jamais découpé. Constaté le 2026-08-09 : le tableau du lot 2 liste 1, 2, 3, 4, 7 sans le dire.
Lot médias — orphelin de lot jusqu'au 2026-08-09
→ v2/media-storage-plan.md § « État mesuré en base le 2026-08-09 »
Ordre à respecter, sous peine de travailler deux fois :
| # | Quoi | Note |
|---|---|---|
| 1 | IStorageService + StoragePath écrit à Create |
La colonne existe mais n'est affectée nulle part : elle reste vide même pour les nouvelles ressources |
| 2 | SizeBytes renseigné à Create |
Aujourd'hui alimenté dans MigrationController, l'endpoint legacy Upload et Update — jamais dans Create, le chemin réel |
| 3 | Backfill (§1ter point 5) | StoragePath est un simple UPDATE SQL (chemin déterministe) ; seul SizeBytes exige de lister Firebase |
| 4 | Flag watermark (§1ter point 6) | Le if instanceId == "633ee379…" de ResourceController:265 est intact, TODO d'origine compris |
| 5-7 | Compression images, quota autoritaire, alerte budget GCP (§1ter 7-9) | Hors schéma, avant la release |
⚠️ Le backfill ne porte pas sur « chaque ligne Resource ». Mesuré : sur 45 lignes, 37 ont un blob, 7 sont des types URL (ImageUrl/VideoUrl/JSONUrl) sans aucun fichier — leur donner un StoragePath ou les compter dans le quota serait faux — et 1 est de type fichier sans URL du tout. SizeBytes vaut 0 partout aujourd'hui : le quota de stockage ne veut rien dire pour personne.
Restes non chiffrés, à caser en cours de route
Ratio jetons → questions ( ✅ des deux côtés le 2026-08-13. Serveur : /1000) à caler sur du réelInstanceQuotaDTO.aiTokensPerQuestion, mesuré sur les VisitorQuestion.TokensUsed de l'instance, avec un seuil de 20 questions avant de faire foi ; en dessous, repli sur l'hypothèse de la grille tarifaire. Front : guide_ia_screen lit ce diviseur au lieu du /1000 codé en dur, et masque la ligne « ~N questions » si l'appel échoue. ⚠️ Le /1000 était faux d'un facteur 10 — la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit 10 000 par question — et c'est un chiffre montré au client : le crédit restant affiché était dix fois trop généreux.
⛔ Endpoint de mise à jour d' n'a jamais manqué (vérifié le 2026-08-13) : ApplicationInstanceApplicationInstanceController porte un CRUD complet (POST :75, PUT :115) et InstanceController.Updateinstance écrit déjà IsMobile/IsTablet/IsWeb (:209-211). Activer un canal est faisable par l'API depuis le début ; ce qui manque est l'écran SuperAdmin, parqué en V2 (voir §« Add-on IA »). ⛔ Quotas seed 5M/20M déjà ajustés par AlignSubscriptionPlansWithPricing : Essentiel 0, Pro 0, Premium 20 M, Enterprise long.MaxValue. Le « 5M » datait d'avant l'alignement.
✅ corrigé, la clé n'existe plus dans features.reqPerMonth de la landingmyinfomate-landing/src (vérifié le 2026-08-11).
Hors périmètre V1, assumé
Ingestion documentaire (PdfPig + OCR + formats + UI IncludeInAiKnowledge, avec quota et StoragePath en prérequis), TTS pré-généré, guides multiples et thématiques, talking head, génération de visites.
Décisions figées — ne pas rouvrir
gemini-embedding-001 à 768 dims · voix Viva (Sulafat) / Marco (Umbriel), ✅ écoutées et validées le 2026-08-07 · découpage V1/V2 · pas de bascule « répondre avec ses connaissances générales » · sources visibles côté client, pas côté visiteur.
Ordre de grandeur total : ~3 semaines, dont une bonne moitié dans le lot 3 et les 13 sous-types.
📍 Reprendre ici — périmé, conservé pour la trace (ordre arrêté le 2026-08-10)
⛔ Ne plus repartir d'ici : les trois étapes ci-dessous sont terminées (A le 10/08, B le 10/08, C le 12/08), et le développement V1 est clos depuis le 2026-08-13. Le point d'entrée est v1-plan.md ; l'état du jour est au § « Les lots F et J sont clos » ci-dessus. Ce qui reste avant la prod est du test, du juridique et de l'infra — plus une ligne de code applicatif.
Les lots 1 et 2 sont terminés. Le lot 3 et le lot médias sont tous les deux obligatoires — l'ordre ci-dessous ne trie pas par importance mais par coût de report. Ni l'un ni l'autre ne bloque plus la bascule : ce qui devait partir avec la migration, c'était le schéma, et il est en place.
| Étape | Quoi | Pourquoi dans cet ordre |
|---|---|---|
StoragePath + SizeBytes renseignés à Create (ResourceController:340, types URL 2/3/7 exclus) + manager-app envoie sizeBytes avant l'upload (resources_screen.dart:210) |
Ce n'est pas un chantier, c'est une fuite : chaque upload créait une ligne incomplète de plus. Corrigée, le backfill de l'étape C est définitif. Reste non autoritaire : la taille vient du client (§4 point 2 du plan médias) | |
| B ✅ exécuté 2026-08-10 | Lot 3 — le RAG, livré et joué contre la vraie base + la vraie API Google : 52 sections → 2150 morceaux en 53 s, recherche cosinus et bonus de langue vérifiés en conditions réelles. Voir le § « Première exécution réelle » ci-dessous | Le jalon démontrable en clientèle est atteint sur le contenu CMS. Reste le jugement produit — la qualité des réponses — qu'aucun test ne donnera |
| C | Reste du lot médias : backfill des 37 lignes + inventaire des orphelins, watermark, compression, quota autoritaire, alerte budget | Le backfill exige de lister le bucket Firebase — plus long, et aucun intérêt à le jouer deux fois |
Amorce pour l'étape A, utilisable telle quelle :
« Lis
DOCS/v2/media-storage-plan.md§5. RenseigneStoragePathetSizeBytesà la création d'uneResourcedansResourceController.Create— c'est le chemin réellement emprunté depuis la bascule Firebase, et aucun des deux n'y est écrit aujourd'hui. Le chemin estpictures/{instanceId}/{resourceId}. Ne rien écrire pour les types URL (ImageUrl,VideoUrl,JSONUrl) : ils n'ont pas de blob. »
Amorce pour l'étape B :
« Lis
DOCS/STATUS.md§1quater puisDOCS/v2/rag-pgvector-integration-plan.md. Le schéma,IEmbeddingService,IVectorStoreService, les 13GetEmbeddableText(), le pipeline Hangfire et l'outilSearchKnowledgesont livrés et compilent. Reste à faire tourner la chaîne pour de vrai contre une base Postgres avec pgvector : vérifier qu'une section enregistrée produit bien ses morceaux, que le guide les retrouve, et régler les deux angles morts du découpage (HTML non retiré, ligne plus longue que la limite non coupée). »