STATUS.md portait encore le lot J et D5 comme à faire, et son « 📍 Reprendre ici »
datait du 10/08 alors que ses trois étapes sont terminées : il est marqué périmé
et renvoie vers v1-plan.md. La ligne « mode offline » du §1 décrivait un pipeline
commenté, corrigé depuis D1 puis D5.
Nouveau § qui consolide la journée : les lots F et J clos, et les cinq pièges
rencontrés — la garde du proactif qui aurait coûté des jetons pour rien, l'écran
de téléchargement qui ne concluait jamais, le choix de voix du client jamais
honoré, les trois écrans qui journalisaient de fausses questions de visiteur, et
le même code copié trois fois ne portant sa décision qu'une seule.
Kanban : le bandeau annonce que le dev V1 est clos. Compteurs mesurés — Fait
récemment 60, aucun bug ouvert. Republié sur l'artifact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 KiB
MyInfoMate — Tableau de bord
Point d'entrée unique : "où j'en suis, qu'est-ce que je peux avancer". Chaque section renvoie vers le document détaillé pertinent — ce fichier ne duplique pas le contenu, il donne juste la vue d'ensemble et l'état.
Dernière mise à jour : 2026-08-09.
1. Produit — features manquantes / en cours
→ Détail complet, granulaire, à jour : todo-features.md
Résumé de la table de suivi de ce fichier :
- Fait : SectionEvent, Escape/Puzzle games, GuidedPath + progression carte, push, stats tracking, sync agenda/météo, traduction IA, quotas (IA/stockage/stats) par plan, audit log backend fiabilisé, refacto SectionParcours/GuidedPath/SectionGame (backend, vérifié 2026-07-15).
- Partiel : Beacons/geofencing (manager-app UI ⚠️), AI Assistant (manager-app ⚠️),
mode offline✅ corrigé le 2026-08-11 (D1) puis complété le 13/08 (D5) — le pipeline de collecte n'est plus commenté, une visite embarque désormais contenus, audios et icônes, et une visite incomplète ne s'annonce plus téléchargée. Reste à confirmer sur device (§21 / D0), c'est le seul point encore ouvert du volet offline. Repasse UI globale, refacto SectionParcours côté manager-app/visitapp-web/mymuseum-visitapp (UI présente mais sous-comportements non revérifiés en détail — voir section "Refactoring majeur" de todo-features.md). - 🧪 Implémenté mais jamais testé (2026-08-05) : onboarding self-service complet — inscription sur myinfomate-landing (
/[lang]/signup), création auto de l'instance + du premier user, essai 14j, Stripe (customer/Checkout/webhook/Tax), 9 emails Resend (domaine vérifié, envoi réel validé), job Hangfire du cycle d'essai, watermark « Aperçu » visitapp-web, écran Abonnement manager-app, mot de passe oublié + invitation user, plafond IA d'essai. Les 4 repos compilent, migrations appliquées en base locale, aucun parcours exécuté → checklist de test danstodo-features.md(section "🧪 Checklist de test end-to-end"). Reste à faire côté config : alias mailonboarding@myinfomate.be, etwhsec_de la Stripe CLI pour tester le webhook en local. - À faire : dashboard super-admin V2, facturation V2. ✅ Le Stripe Customer Portal est complet le 2026-08-13 (backend + bouton manager-app).
- ⚠️ Cette ligne était périmée sur six points, revérifiés dans le code le 2026-08-13. Sont faits : écran audit log manager-app (front livré le 12/08), rate limiting API Keys (livré, pas seulement décidé — politique
aidansStartup.cs:279,UseRateLimiter()en:344, les deux endpoints décorés), gestion des users par instance (plafond serveur + compteur UI, 12/08), nettoyageSectionEvent.ParcoursIds(11/08), et le backend du Customer Portal (13/08). L'unificationQuestionTypen'est pas « à faire » mais écartée : le trio TextLibre/Digicode/ExpectedAnswer n'a jamais existé dans le code, l'enum a trois valeurs et le digicode est un comportement dérivé, pas un type — reste une propreté front, elle aussi livrée (alias nommés, 11/08).
- ⚠️ Cette ligne était périmée sur six points, revérifiés dans le code le 2026-08-13. Sont faits : écran audit log manager-app (front livré le 12/08), rate limiting API Keys (livré, pas seulement décidé — politique
- 📦 Reporté en V2 (décidé le 2026-08-07) : SectionForm, ressource 360°, AR image tracking (Mind AR). Les trois sont spécifiés en détail dans todo-features.md mais sortent du périmètre V1 — aucun n'est un prérequis de la migration Postgres ni de la mise en prod. À reprendre tels quels quand la V1 sera stabilisée.
Prochaine chose à faire si tu as 1h : jouer le §19.13 cas E du plan de test (chasse au trésor / carnaval) de bout en bout, depuis la création dans manager-app. C'est le scénario le plus complet — il valide SectionMap + SectionParcours + géodéclenchement + questions + mode jeu d'un coup.
1bis. État des builds — mesuré le 2026-08-06
Aucune app n'était buildable au début de l'audit. Deux corrections ont débloqué les 3 apps Flutter. Détail : parity-manager-visitapp.md §6 · checklist : test-plan.md §0
| Repo | Build | Note |
|---|---|---|
| manager-service | ✅ dotnet build et ✅ dotnet test 124/124 (2026-08-06) |
La remise en route de la suite a révélé un bug backend réel : BuildAuditEntries() ajoutait un AuditLog au contexte pendant l'énumération du ChangeTracker → InvalidOperationException: Collection was modified sur toute écriture d'entité auditée (Section, Resource, Configuration, Device, User, Instance). Corrigé : énumération matérialisée, logs ajoutés après la boucle. Invisible jusque-là parce que les tests ne compilaient plus |
| manager-app | ✅ flutter build web |
débloqué : // @dart=2.18 manquant dans manager_api_new/lib/api/onboarding_api.dart (fichier ajouté à la main pour l'onboarding, sans l'annotation de version que porte api.dart) — cassait les 3 apps Flutter d'un coup |
| mymuseum-visitapp | ✅ flutter build apk — les 3 flavors, vérifiés le 2026-08-12 (dev, mdlf, fortsaintheribert) |
⛔ Le ❌ « revérifié le 2026-08-11 » était périmé. Le build passe, et sa config Android était déjà à niveau — c'est tablet-app qui était en retard sur lui, pas l'inverse. K5 s'est donc réduit à deux alignements que le build réclamait en avertissement : NDK 27.0.12077973 → 28.2.13676358 (speech_to_text le réclame nommément) et Kotlin 2.1.0 → 2.3.10 (seuil 2.2.20 annoncé par Flutter ; même version que tablet-app). L'APK passe de 382 à 349 Mo. Les 4 erreurs Dart Ray-Ban restent hors du graphe de main.dart et ne gênent pas le build (§5bis) |
| visitapp-web | ✅ npm run build |
débloqué : helper getGeoPointLatLng() dans src/lib/geo.ts, les 7 erreurs venaient d'un unknown non narrowé. ⚠️ ce type d'erreur est invisible en next dev |
| tablet-app | ✅ flutter build apk — réparé le 2026-08-12, APK produit pour la première fois depuis le 16 avril |
⛔ Le diagnostic « un seul défaut, purement Gradle, peut-être réglé par 5a6701d » était doublement faux. Il a tenu des mois parce que personne n'avait lancé la commande. Réalité : dix crans d'outillage (Gradle 7.5 → 8.11.1, AGP 7.2.0 → 8.9.1, Kotlin 1.9.0 → 2.3.10, option AGP supprimée, jcenter mort, heap Gradle 1536M → 4096M, Jetifier coupé, resolutionStrategy sur androidx.lifecycle retiré) — corrigés le 2026-08-12 — puis 132 erreurs Dart que le Gradle masquait : l'app est restée sur l'ancien contrat d'API, celui d'avant Postgres v3. Détail complet et suite : v1-plan.md lot K |
Leçon : flutter analyze et next dev ne suffisent pas. Seuls flutter build / npm run build / dotnet test disent la vérité — d'où la nouvelle section §0 du plan de test.
Leçon renforcée le 2026-08-12 : un build jamais lancé ne vaut pas mieux qu'un build vert supposé. Trois affirmations de ce tableau ont sauté d'un coup dès qu'on a tapé la commande. Corollaire à retenir : « à reconfirmer par un vrai build » dans une doc veut dire que ce n'est pas confirmé, et un point non confirmé ne doit pas être chiffré comme une demi-heure.
⚠️ Et un piège de vérification à connaître : flutter build apk … | tail renvoie le code de sortie du tail, pas celui du build. Un échec y ressort en « exit code 0 ». Rediriger vers un fichier et lire $? séparément.
1ter. Chantier « migration Postgres v3 » — ordre de bataille (ajouté le 2026-08-07)
Postgres n'est pas encore en prod. Trois chantiers ont été analysés le 2026-08-07 et découpés selon une règle unique :
Ce qui touche aux données déjà écrites part avec la migration. Ce qui est purement additif attend.
Chaque ligne est faite pour être attaquée dans une conversation dédiée : lire le doc, faire l'étape.
⚠️ Cette section dit ce qui doit entrer dans le schéma, pas comment on remplit la base. L'opération de bascule elle-même — et l'état de
MigrationController— sont au §1quinquies.
Doit partir avec la migration (schéma — quelques heures)
| # | Quoi | Doc | Pourquoi maintenant |
|---|---|---|---|
| 1 | ✅ Fait 2026-08-09 — Image Docker postgis + pgvector (Deployment/Dockerfile.postgres, digest épinglé) |
v2/rag-pgvector-integration-plan.md | Base vide = 4 lignes de Dockerfile. Plus tard = fenêtre de maintenance sur la prod |
| 2 | ✅ Fait 2026-08-09 — Table ContentEmbedding + index HNSW |
idem | Table vide, ne dérange personne. Additif mais gratuit maintenant |
| 3 | ✅ Fait 2026-08-09 — IEmbeddingService + GoogleEmbeddingService, dimension 768 validée contre l'API réelle |
idem | Gèle la dimension du vecteur — décision de schéma, à valider avant la migration |
| 4 | ✅ Fait 2026-08-09 — Colonnes Resource : StoragePath, FileName, IncludeInAiKnowledge, AiIndexStatus… (SizeBytes existait déjà) |
v2/media-storage-plan.md | Les URL Firebase absolues sont dispersées jusque dans le JSONB des sections |
| 5 | Backfill SizeBytes + StoragePath + inventaire des blobs orphelins |
idem | La migration touche déjà chaque ligne Resource |
| 6 | Flag watermark sur Instance (+ exposition dans la config) |
idem | Remplace un if sur un id d'instance codé en dur |
Avant la release, hors schéma
| # | Quoi | Doc |
|---|---|---|
| 7 | Compression images côté client — 2560 px / JPEG q82, ~12× de gain | v2/media-storage-plan.md |
| 8 | Alerte de budget GCP (5 min) | idem |
| 9 | Quota : pré-vol + contrôle autoritaire à Create + Delete du blob |
idem |
Bugs découverts — indépendants de la migration
| # | Quoi | Doc |
|---|---|---|
| 10 | Visite hors ligne quasi non fonctionnelle — le switch de collecte des ressources est commenté côté backend et côté visitapp : ni images d'articles, ni audios |
v2/offline-visit-plan.md |
| 11 | Ressource modifiée jamais re-téléchargée (filtre sur la présence, pas la version) | idem |
| 12 | audio/mpeg absent de la table d'extensions → MP3 probablement en .unknown |
idem |
| 13 | Purge des fichiers obsolètes désactivée sur le device | idem |
V1 — après la migration (décidé le 2026-08-07)
Le RAG est dans la V1, dans son périmètre restreint : IVectorStoreService, jobs Hangfire sur le contenu CMS, endpoint /ask, et l'écran Guide IA dans manager-app. Pas d'ingestion documentaire, pas d'OCR, pas de TTS — c'est ce qui le rend tenable.
Deux écrans manager-app sont spécifiés et maquettés :
| Écran | Plan | Maquette |
|---|---|---|
| Guide IA — 🔨 onglet Configuration livré le 2026-08-07 | v2/guide-ia-screen-plan.md | https://claude.ai/code/artifact/c43fe02d-3283-48c2-992f-d687e18acdfa |
| Statistiques — 🔨 refonte livrée le 2026-08-07 | v2/stats-screen-plan.md | https://claude.ai/code/artifact/052fe8f3-0288-4847-b63f-4c7de06068ec |
Tableau de chantiers global : https://claude.ai/code/artifact/4c317904-2e23-45d3-ac14-dcca678ae5d3
🔨 Premier morceau livré le 2026-08-07 — migration
AddGuideIaConfigToInstance(4 colonnes nullable, additive), champs surInstance/InstanceDTO, clientmanager_api_newétendu à la main, écranScreens/GuideIa/avec 35 clés i18n FR/EN/NL.dotnet buildetflutter build webpassent.✅
AssistantServicebranché sur la configuration le 2026-08-07 — l'écran ne pilote plus dans le vide. C'est le lot 1 du §1quater ci-dessous, qui donne l'ordre d'exécution de tout le chantier Guide IA.📄 Repartir de zéro dans une conversation neuve : ordre de lecture et amorce à copier dans v2/guide-ia-screen-plan.md § « Reprendre ce chantier dans une conversation neuve ».
Relevé en vérifiant : tout le system prompt est codé en dur, dans deux blocs…Corrigé le 2026-08-07. Il y avait en fait quatre blocs de prompt et non deux ; les quatre lisent maintenant la configuration du client. Détail : v2/guide-ia-screen-plan.md.
🔨 Statistiques — refonte livrée le 2026-08-07, export PDF compris,
Screens/Statistics/statistics_screen.dartréécrit, aucun changement backend. Les 7 défauts sont corrigés : barres horizontales monochromes à la place des barres verticales tronquées et des deux anneaux, barre de filtres unique avec les volumes par canal, canaux filtrés sur ceux que l'instance possède réellement, règle mono-canal, 4 KPI portant chacun leur variation, bandeau « à retenir », courbe en aire avec bandes de week-end et survol, bloc rapport avec bouton de téléchargement actif (PDF généré côté client, paquetflutter analyze lib/Screens/Statisticsest propre ;flutter test test/statistics_report_test.dart4/4.⚠️ Rien de tout ça n'a été ouvert dans un navigateur — ni l'écran sur des données réelles, ni le PDF produit. Cases de test : test-plan.md §8bis (écran, 9 cas) et §8ter (export PDF, 6 cas). Trois écarts assumés par rapport au plan (comparaison de période côté client, bandeau généré côté client, « visiteurs uniques » remplacé par « contenus consultés » car non calculable) : détail dans v2/stats-screen-plan.md § État d'implémentation.
ℹ️ Précision sur
flutter analyze: le projet a 68 erreurs, toutes dans les fichiers modèle orphelins demanager_api_new(+lib/api/openApiTest.dart) — c'est la dette « client généré désynchronisé » déjà listée, pas une régression.flutter build webpasse malgré elles : ces fichiers ne sont pas dans le graphe de compilation. Ne pas lire unflutter analyzeglobal comme un feu vert, filtrer sur le dossier travaillé.
⚠️ Point de correction V1 : l'indexation doit respecter IsActive. Une section non publiée ne doit pas être indexée, et la désactiver doit purger ses embeddings — sinon le guide révèle du contenu que le client prépare.
V2 — incréments
Côté IA : ingestion documentaire (PDF/Word/images + OCR Gemini), TTS pré-généré, talking head, génération de visites, guides multiples et guide thématique par section. Détail : v2/rag-pgvector-integration-plan.md.
Côté produit — reporté en V2 le 2026-08-07 (spécifié dans todo-features.md, rien n'en dépend en V1) :
| Chantier | Pourquoi ça peut attendre |
|---|---|
| SectionForm — formulaires personnalisés (feedback, satisfaction, inscription atelier) | Nouveau type de section + table de réponses : purement additif, aucun impact sur le schéma existant |
Ressource 360° — images panoramiques (ResourceType.Panorama360 + package panorama) |
Une valeur d'enum et un branchement dans l'affichage des ressources ; se greffe sans toucher aux sections |
AR image tracking (Mind AR) — modèle ArAnchor, microservice Node de compilation .mind, WebView scanner unifiée QR + image |
Le plus gros des trois : nouveau service Docker, nouveau modèle, refonte du scanner visiteur. Fort effet démo, zéro urgence |
Kiosk web — version Flutter Web de tablet-app |
Aucun client ne le demande. ⚠️ Ne pas confondre avec le visiteur web (visitapp-web), lui déjà écrit et attendu en V1. Mise à jour du 2026-08-12 : l'argument « et tablet-app ne compile pas » est tombé, l'APK est produit depuis K1. |
| Portrait et paysage sur toute la borne — ajouté le 2026-08-12 | tablet-app n'a aucune stratégie d'orientation : les écrans sont écrits pour la forme qu'on leur a vue, et la maquette K3/K7 tranche le paysage pour deux types seulement. Or une borne se monte aussi bien en portrait (totem d'accueil) qu'en paysage (comptoir), et le choix appartient au lieu, pas à l'app. Le chantier n'est donc pas « ajouter le portrait » mais rendre les treize types indifférents à l'orientation — un jeu de points de rupture et une règle unique du type « la colonne de texte reste plafonnée, le rail passe dessous quand la largeur manque ». C'est une repasse UI transversale, pas un correctif : hors V1 pour la même raison que la repasse UI globale de manager-app (DB1). ⚠️ La maquette du 12/08 est compatible — le rail qui tombe sous le texte est exactement le comportement portrait — mais elle ne l'a pas spécifié |
Prochaine chose à faire si tu as 1h : le §21 du plan de test — vérifier sur un device l'état réel de la visite hors ligne. C'est peut-être déjà remonté comme « l'appli marche mal dans le fort » sans que la cause ait été identifiée, et ça conditionne l'urgence des points 10-13.
1quater. Chantier « Guide IA V1 » — ordre d'exécution (arrêté le 2026-08-07)
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). »
1sexies. Ordre d'exécution de toute la V1 — et le lot design (ajouté le 2026-08-11)
Ce fichier dit ce qu'il reste, par domaine. Le kanban dit où en est chaque chantier. Ni l'un ni l'autre ne disait dans quel ordre, ni ce qui bloque quoi.
→ v1-plan.md — 9 lots (A à I plus D-bis), 18 liens de dépendance, chemin critique, arbitrages en attente. 4 à 5 semaines, portées à 5-6 avec le lot design.
Chemin critique : A (assainir) → B (geler le schéma) → G (MigrationController) → H (tests) → J (RGPD) → I (bascule). C, D, D-bis, E, F se parallélisent.
Lot K — les apps visiteur parlent l'ancien contrat (ajouté le 2026-08-12)
Un lot entier qui manquait, et il bloque la bascule. Découvert en lançant le premier vrai flutter build apk sur tablet-app depuis des mois — la commande que trois documents disaient « à reconfirmer ».
Deux couches, pas une : dix crans d'outillage Android (corrigés le 12/08, détail dans v1-plan.md lot K — Gradle 7.5 → 8.11.1, AGP 7.2.0 → 8.9.1, Kotlin 1.9.0 → 2.3.10, jcenter mort, heap Gradle relevée, Jetifier coupé, forçage d'androidx.lifecycle retiré), puis 132 erreurs Dart que le Gradle masquait depuis le début.
⚠️ Le compte de « six crans » qui circulait plus tôt dans la journée était provisoire : il datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite. C'est le même piège que le tableau ci-dessus documente — un état intermédiaire pris pour un état final.
Les 132 erreurs tiennent en 3 causes : ConfigurationDTO.roundedValue/isDate/isHour/isSectionImageBackground/screenPercentageSectionsMainPage ont migré vers AppConfigurationLinkDTO (73) · GeoPointDTO.latitude/longitude sont devenus GeometryDTO geometry avec PostGIS (40) · les min() sur dynamic en découlent (19). mymuseum-visitapp a déjà fait ce portage (visitAppContext.currentAppConfigurationLink, Models/visitContext.dart) : c'est une copie, pas une conception. Côté visitapp-web, le pendant existe aussi — le helper getGeoPointLatLng() de src/lib/geo.ts.
⚠️ Ce qui en fait un bloquant de la bascule — lien L19, que le plan ne portait nulle part. Les tablettes en service chez MDLF et au Fort parlent l'ancien contrat. Le jour J, elles ne s'arrêteraient pas proprement : cartes sans points, écrans dégradés, aucune erreur visible. Et le parc des téléphones visiteurs, lui, ne se force pas — d'où la stratégie de coexistence adoptée au lot I (deux serveurs, l'ancienne prod figée, aucun DNS qui bouge).
✅ K5 livré le 2026-08-12 — et le lot n'était pas ce que le plan décrivait. Les trois flavors de mymuseum-visitapp (dev, mdlf, fortsaintheribert) construisent, exit 0, APK à l'appui. La prémisse « son APK ne compile pas » était périmée : sa config Android était déjà à niveau (Gradle 8.11.1, AGP 8.9.0, -Xmx4096M, enableJetifier=false, aucun forçage d'androidx.lifecycle). Les dix crans du 12/08 ont amené tablet-app jusqu'à mymuseum — il n'y avait pas la même migration à refaire ici. Le réflexe du diff noté ci-dessus a donné la réponse en deux cat.
Restait le contenu réel du lot, les deux avertissements du build : NDK 27.0.12077973 → 28.2.13676358 (speech_to_text le réclame nommément) et Kotlin 2.1.0 → 2.3.10 (sous le seuil 2.2.20 annoncé comme rupture par Flutter ; c'est la version de tablet-app, donc un alignement, pas une version neuve). Six builds — les trois flavors avant, les trois après. APK de 382 à 349 Mo.
⚠️ Un cran de plus existe, délibérément non pris. Kotlin réglé, le validateur Flutter en révèle deux autres : Gradle 8.11.1 → 8.14.0 et AGP 8.9.0 → 8.11.1, « soon be dropped ». Même cascade que les dix crans de tablet-app — chaque correctif découvre le suivant. Arrêté là parce que c'est du terrain neuf pour les deux repos (tablet-app est à 8.11.1/8.9.1, mêmes avertissements latents) : le faire ici seul désaligne les deux apps au lieu de les aligner. À faire d'un bloc sur les deux, ou pas du tout — c'est la dette d'outillage déjà parquée en V2.
✅ Question de branche, à trancher avant K6 — tranché le 2026-08-13 : Meta-Rayban-Test (et AI-Assistant-test côté tablet-app) sont les branches de travail à jour. On développe et on publie depuis elles, il n'y a pas de merge préalable à faire. Voir le §5bis, dont la description « POC jamais mergé » est ce qui avait fabriqué cette alerte.
Ce que K5 débloque : D0 (test-plan §21 sur device), donc la mesure des bugs offline D2-D5 et la confirmation du volet visiteur de D1 — qui ne tient aujourd'hui que sur flutter analyze.
✅ D2, D3 et D4 livrés le 2026-08-12, sans attendre D0 (arbitrage du plan : défauts certains, pas des hypothèses à mesurer). audio/mpeg ajouté à la table d'extensions, purge des fichiers obsolètes réactivée, et filtre de fraîcheur livré de bout en bout (dateUpdate serveur → DTO → client généré → base locale v4). dotnet test 205/205, flutter build web (manager-app) ✅, APK dev de mymuseum-visitapp ✅. Reste D0 et D5.
⚠️ Deux prémisses du plan étaient fausses, corrigées dans v1-plan.md :
- « Les deux chemins de téléchargement divergent » (le motif
Create/Uploadde C1/C3, rapproché de D4) : il n'y a qu'un seul chemin vivant. Les lignes328-461dedownloadConfiguration.dartsont dans un bloc commenté — lecleanLocalResources(:455)cité comme preuve est du code mort, et cette fonction n'est appelée nulle part. cleanLocalResourcesn'est pas réactivable telle quelle : la table localeresourcesn'a pas de colonneconfigurationId, elle est globale à toutes les visites téléchargées. L'activer sur les ids d'une seule configuration aurait effacé les lignes des autres. Sans bénéfice, de surcroît : rien ne lit ces lignes pour le rendu, le fichier est retrouvé en listant le répertoire.
⚠️ D2 ne réparait pas ce que la doc annonçait — vérifié dans le code le 2026-08-12. « Une image remplacée dans le CMS ne remonte jamais sur le device » : ce cas n'existe pas. Upload génère un nouvel id à chaque téléversement, manager-app écrit dans pictures/{instanceId}/{resourceId} — remplacer une image donne donc un nouvel id et une nouvelle URL, que l'ancien filtre téléchargeait déjà. La popup d'édition ne change que le libellé. Aucun flux ne réécrit le blob d'un id existant.
✅ Le vrai cas de péremption est celui que D3 vient de créer : les MP3 déjà sur les devices y sont en <id>.unknown, et « le fichier est présent » les déclarait à jour. Sans D2, D3 ne réparait que les installations neuves — le parc existant serait resté muet. isResourceOutdated traite .unknown comme absent.
⚠️ Deux pièges tranchés en écrivant D2 : la montée v3 → v4 laisse les dates existantes à NULL et les considère à jour (backfiller à zéro aurait fait re-télécharger toutes les visites de tous les visiteurs sur leur réseau mobile) ; et la boucle en masse héritée de D1 écrasait la date que la boucle de téléchargement venait d'écrire, DatabaseHelper.insert faisant un UPDATE de la ligne entière.
⚠️ Dette relevée au passage, non traitée : les trois lib/api/swagger.yaml (manager-app, mymuseum-visitapp, tablet-app) sont périmés — leur ResourceDTO n'a ni sizeBytes (C1/C3) ni dateUpdate. Ce sont des artefacts de génération, et le client s'édite à la main : ils ne sont plus la source de vérité, mais ils la contredisent en silence.
✅ K3 et K7 livrés le 2026-08-12 — flutter build apk --debug vert, APK produit. La borne couvre désormais 12 des 13 types : SectionArticle (K7) et SectionEvent (K3) sont branchés dans main_view.getContent. Seul SectionParcours reste dehors, et définitivement — un parcours guidé fait marcher le visiteur.
Les deux écrans suivent la maquette DOCS/claude design/kiosk-paysage-article-event.html : rail média ancré + colonne de lecture plafonnée pour l'article, bande héros à 26 % + programme + carte vive pour l'événement, détail d'un bloc sous la carte plutôt qu'en showModalBottomSheet.
Quatre choses que le code a dictées, contre ce que la maquette ou mymuseum annonçaient :
- ⚠️ La barre de pied de la maquette n'appartient pas aux écrans.
section_page_detail:184/198dessine déjà le bouton retour, avec les clésback/menuselonisFromMenu. Les écrans qui auraient dessiné le leur en auraient affiché deux. La maquette est à corriger sur ce point. - ⚠️ Un seul constructeur de marqueurs au lieu des quatre de mymuseum.
MapAnnotationDTOetMapAnnotationont des champs rigoureusement identiques mais sont deux types Dart distincts — d'où la duplication là-bas. Normalisé ici par une classe interne à deux fabriques. C'est L5 appliqué au front, et il porte d'autant plus que la convention[lng, lat]est l'endroit exact où une divergence ne lève aucune erreur. - ⚠️ L'audio d'article se résout dans
contentsavant l'API.ContentDTOporte sonresourcecomplet — idiome déjà utilisé parmarker_view— donc l'audio est trouvé sans appel réseau quand il est embarqué, donc hors ligne aussi.resourceGetDetailn'est qu'un repli. Mymuseum appelle l'API systématiquement en ligne. - ⚠️ Nouvelle clé i18n
event.livedans les 10 langues. Sans les 10,getFromLocalerenvoie""et la pastille « en cours » rendrait une boîte vide. FR, EN, NL, DE, IT, ES sont sûres ; PL, CN, UK et AR sont de ma main et demandent une relecture humaine.
⚠️ Ce qui n'est pas prouvé : le rendu. Aucun des deux écrans n'a été vu à l'œil — build vert ne veut pas dire mise en page juste, c'est exactement la leçon du §1bis appliquée au design. Et aucun SectionEvent n'existe en base (le type est né avec Postgres v3) : c'est le §19.13 cas E qui en créera un.
Périmètre kiosk tranché le 2026-08-12 : tablet-app couvre 11 des 13 types — ⛔ corrigé le 2026-08-12 : c'est 10, et le compte cachait un vrai manque. Le switch de main_view.getContent traite Map, Slider, Video, Web, Menu, Quiz, Pdf, Game, Agenda, Weather. SectionArticle (type 6) n'est pas traité : son case est commenté « TODO » depuis l'origine et Screens/Article/article_view.dart fait 1 Ko. Un article tombe donc dans le default — « Ce type n'est pas supporté ». Contrairement à SectionEvent et SectionParcours, ce type-là existait dans Mongo : c'est un vrai retard, pas un type né avec Postgres v3. À trancher — le supporter ou l'assumer — mais pas à compter comme couvert. SectionParcours est exclu définitivement (un parcours guidé fait marcher le visiteur — pas de sens sur borne fixe) ; SectionEvent est à supporter (borne d'accueil affichant le programme du jour, §19.13 cas E). Ni l'un ni l'autre n'est un retard : les deux types sont nés avec Postgres v3. S'ajoute le renommage SectionPuzzle → SectionGame et le nouveau SlidingPuzzle, à récupérer depuis mymuseum-visitapp/lib/Screens/Sections/Game/ — sa version est moins buguée que le Puzzle/ de tablet-app.
Ce que ça coûte : +3 à 5 jours. Ce n'est pas du périmètre ajouté, c'est du travail déjà dû que personne n'avait vu.
✅ tablet-app construit à nouveau — flutter build apk --debug vert le 2026-08-12. K1, K2 et K4 sont livrés et prouvés par un build, pas seulement par flutter analyze. L'outillage a demandé dix crans (le compte de « six » écrit plus tôt dans la journée datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite) — tableau complet dans v1-plan.md lot K.
⚠️ Trois des quatre derniers crans étaient de simples alignements sur mymuseum-visitapp : -Xmx4096M au lieu de 1536M, enableJetifier=false, et surtout le retrait d'un resolutionStrategy qui forçait androidx.lifecycle à 2.4.0 avec le commentaire « To fix mapbox issue » — il réglait un problème mapbox d'il y a trois ans et causait celui d'aujourd'hui (mapbox_maps_flutter 2.21.1 appelle setViewTreeLifecycleOwner, absent de 2.4.0). J'ai cherché ces causes une par une alors qu'un diff des deux gradle.properties et des deux build.gradle les donnait toutes d'un coup. Réflexe pour K5 : commencer par ce diff.
✅ K4 livré le 2026-08-12 — les 6 fichiers de mymuseum-visitapp/lib/Screens/Sections/Game/ repris dans tablet-app/lib/Screens/Game/, Screens/Puzzle/ supprimé, main_view sur SectionType.Game. La tablette gagne le puzzle glissant, le bouton d'indice et un dimensionnement au ratio de l'image. ⚠️ Un choix de rendu à valider à l'œil : mymuseum code ses couleurs en dur par flavor client (rouge MDLF, bleu Fort) ; tablet-app n'ayant pas de flavor — un seul APK, la charte vient de la configuration — le dégradé est dérivé de configuration.primaryColor. Le rendu diffère donc de la version mobile.
✅ K2 livré le 2026-08-12 — les 132 erreurs de contrat sont tombées. flutter analyze lib ne renvoie plus que 6 erreurs PuzzleDTO, qui relèvent de K4. Livré : TabletAppContext.currentAppConfigurationLink, lib/Helpers/geo.dart (extension lat/lng + coordinateKey), 67 accès roundedValue rebranchés, main_view et les 4 vues carte portées.
Deux bugs latents trouvés au passage — la raison pour laquelle un chercher-remplacer aurait été dangereux :
- ⚠️
applicationInstanceDTOservait de drapeau d'assistant. Il n'était assigné que siinstanceDTO.isAssistant == true(main_view:339). Ce DTO portant désormais lesAppConfigurationLink, le nouveau getter aurait renvoyénullsur toute instance sans IA — donc MDLF et le Fort Saint-Héribert — etroundedValue,isDate,isHour,isSectionImageBackground,screenPercentageSectionsMainPageseraient tous retombés sur leurs valeurs par défaut. Sans erreur, sans log : des écrans qui ne ressemblent plus à ce qui est configuré. Corrigé par une assignation inconditionnelle et un drapeau expliciteisAssistantEnabled, qui préserve le ET instance × canal — les deux gardes qu'on retrouve côté serveur enAiController:315et:360. - ⚠️ Le filtre « configurations pour tablette » n'avait plus de source.
ConfigurationDTO.isTableta disparu : le canal est porté par l'ApplicationInstanceet le rattachement vit dans sesAppConfigurationLink.getConfigurationsfiltre désormais là-dessus. À valider sur device (§0/§18) : si un client a des configurations non rattachées à son canal tablette, l'écran de sélection sera vide. L'ancien code avait le même angle mort, mais la donnée qui pilote le filtre a changé — donc à revérifier sur les 4 instances.
⚠️ Troisième piège de vérification de la journée : flutter analyze écrit error - , pas error • . Un grep sur la mauvaise forme a fait conclure « 0 erreur » alors qu'il en restait 10. Après le | tail qui masque le code de sortie, c'est le deuxième faux vert obtenu par un filtre mal formé — toujours compter les erreurs en relisant le log complet.
⚠️ Deux conventions de coordonnées coexistent — vérifié le 2026-08-12, ne pas « harmoniser » à l'aveugle
Le projet écrit les coordonnées dans deux ordres différents selon le producteur. Les deux sont en production, et une confusion ne lève aucune erreur : elle place les points ailleurs, sur une carte qui reste plausible.
| Donnée | Ordre | Écrit par | Lu par |
|---|---|---|---|
GeoPointDTO.geometry, et toute géométrie passée par GeometryMapper |
[lng, lat] — GeoJSON standard |
GeometryMapper.ToDto sérialise { point.X, point.Y } ; MigrationController:798 construit new Point(lon, lat) |
mymuseum-visitapp/Models/agenda.dart:72 lit bien coords[1]=lat, coords[0]=lng · tablet-app/lib/Helpers/geo.dart (créé le 12/08) |
GuidedStep.geometry |
[lat, lng] — inversé |
manager-app, map_geometry_picker |
visitapp-web/src/lib/geo.ts, qui le documente explicitement |
Pourquoi on ne corrige pas : l'ordre inversé des GuidedStep est déjà écrit en base. Le redresser demanderait une migration de données et une reprise simultanée de tous les lecteurs — pour un gain purement esthétique, sur un chantier qui n'en a pas besoin avant la bascule.
Ce qu'il faut faire à la place : ne jamais recopier un helper de coordonnées d'un contexte à l'autre. getStepGeometryCenter (visitapp-web) et GeoPointPosition (tablet-app) se ressemblent et sont incompatibles. Chaque helper porte sa convention en commentaire — les lire avant de s'en servir.
⚠️ À rouvrir si SectionParcours arrive un jour dans une app qui lit aussi des GeoPoint : elle aurait alors les deux conventions dans le même écran. C'est aujourd'hui le cas de visitapp-web et de mymuseum-visitapp — vérifié : chacun utilise le bon helper pour la bonne donnée, mais c'est l'endroit exact où une future erreur se logera.
D1 — le bug offline nº1 est corrigé (2026-08-11). Le switch de collecte des ressources était commenté des deux côtés : 157 lignes mortes dans ConfigurationController.Export, 126 dans Import, et le pendant dans downloadConfiguration.dart. Une visite téléchargée n'embarquait donc que l'image de la configuration, celle du loader et l'image de chaque section.
Remplacé par GetReferencedResourceIds(), déjà implémentée sur les 13 sous-types. Côté import et côté client, la charge est enregistrée en une passe au lieu d'être redécouverte section par section — ce qui répare au passage la purge des fichiers obsolètes, pilotée par usedImageOrAudioIds. 4 tests, dont un qui vérifie par réflexion que les 13 sous-types implémentent la collecte : le trou venait d'un switch où un type oublié passait dans le default sans bruit. dotnet test 148/148.
⚠️ Le volet visiteur ne tient que sur flutter analyze — l'APK de mymuseum-visitapp ne compile pas (Gradle/NDK, §1bis). À confirmer sur device au §21.
Lot G — les 8 écarts sont clos et le dry run est joué (2026-08-11). dotnet build vert, dotnet test 143/143.
Trois des huit n'existaient pas. Le plan les décrivait en « perte de données » ; l'export Mongo dit qu'il n'y a rien à perdre :
- (a)
BuildSectioncouvre exactement les types présents : les 327 sections de l'export ne contiennent que les valeurs 0 à 10.SectionEventetSectionParcourssont nés avec Postgres v3. - (f)
IsQRCode/IsSearchText/IsSearchNumbern'existent pas dans Mongo —falseest le bon défaut. Contrôle inverse fait : les cinq réglages qui existent (IsDate,IsHour,IsSectionImageBackground,RoundedValue,ScreenPercentageSectionsMainPage) sont bien mappés surAppConfigurationLink. - (g) tombé avec le rename du lot B.
Recette de bascule : test-plan.md §22 — comparaison contenu par contenu entre l'app d'aujourd'hui et la base migrée, à jouer pendant que Mongo est encore lisible. Reliée depuis I7.
Dry run : automatisé en test (MigrationDryRunTests, se saute sans Mongo), joué sur l'export du 1ᵉʳ avril. Il a trouvé du premier coup 20 sections manquantes avec Erreurs : 0 — elles référencent 2 configurations supprimées dans Mongo (contenu MDLF). Pas un défaut de migration, mais un silence : elles sont désormais signalées dans Skipped. ⚠️ Décision produit en attente : recréer les 2 configurations avant la bascule pour récupérer ce contenu, ou acter sa perte. ⚠️ À rejouer sur un dump frais avant le jour J — l'export a 4 mois et la prod tourne encore sur Mongo.
Les cinq vrais, corrigés :
- (b) mesuré : 5 sections quiz portent 41 questions, toutes jetées. Migrées avec leurs réponses, en
MultipleChoice(le type de validation n'existait pas dans l'ancien modèle). Les agendas n'ont aucun événement dans Mongo et les parcours n'y existent pas : leurs collections vides sont correctes. - (c) tout est généré ou dérivé, cf. v1-plan.
- (d) Mongo n'a pas de champ Role : le
ContentEditoren dur dégradait les 10 utilisateurs en silence. Passé àInstanceAdmin. ⚠️ AucunSuperAdminn'est créé par la migration. - (e) passe par
ResourceStorage(L5) ;StoragePathn'était pas écrit du tout. L'échec duHEADremonte désormais dans le rapport. - (h) transaction unique, et 500 au lieu de 200 OK sur échec fatal.
Lot F — le volet manager-app est livré le 2026-08-12 : écran d'audit log et compteur d'utilisateurs. flutter build web ✅, flutter analyze propre sur les dossiers travaillés. manager_api_new n'a pas été touché — l'écran passe par un http.get direct au Bearer, comme _loadKnowledge / _loadInsights / _reindex du Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main, et le déclencheur de génération n'existe plus depuis le lot A.
- Écran d'audit :
Screens/Audit/audit_screen.dart+audit_entry.dart, entrée de menumenuId: 13conditionnée àrole.value == 0— même règle que la policySuperAdminde l'endpoint. Filtres instance / type d'entité / utilisateur / plage de dates, pagination 50, clic sur une ligne → détail avant-après en table. Les ids d'instance et d'utilisateur sont résolus en noms viainstanceGet()/userGet(), un id inconnu de l'annuaire restant affiché tel quel : le journal doit rester lisible après la suppression de ce qu'il décrit. - Compteur utilisateurs : « X / 5 » et bouton d'ajout désactivé au plafond. Le SuperAdmin en est exclu — son
GET /api/Userrenvoie toutes les instances, compter cette liste contre un plafond par instance n'aurait aucun sens. Le point « masquer le rôle SuperAdmin à un InstanceAdmin », resté en question danstodo-features.md, était déjà fait (_allowedRolesfiltre surr >= callerRole) : vérifié, pas réimplémenté. - Trois pièges relevés en câblant, à ne pas re-découvrir :
AuditControllerrenvoie les entités brutes, pas un DTO (champs d'AuditLogen camelCase) ;invokeAPIne lève pas sur un code d'erreur et son résultat était ignoré dansusers_screen, donc un e-mail déjà pris (409) ne produisait aucun message — corrigé, ce qui rendra le futur 422 du plafond visible sans retouche ; etDropdownButtonFormFieldne relit pasinitialValuesur reconstruction (FormField.didUpdateWidgetne traite queforceErrorText), donc « Réinitialiser les filtres » vidait la requête sans vider l'affichage — remplacé par unDropdownButtonpiloté.
✅ Les deux dettes backend qu'il avait ouvertes sont fermées le 2026-08-12 — voir le volet manager-service ci-dessous.
Lot F — le volet manager-service est livré le 2026-08-12 : les quatre chantiers de dette backend. dotnet test 163 → 200, aucun test sauté. Quatre commits, manager-service seul.
✅ Volet manager-service refermé le 2026-08-13 — Stripe Customer Portal (backend) et ratio jetons → questions calé sur du réel. 6 tests, dotnet test 211 : 197 passés, 14 sautés (Testcontainers sans démon Docker), 0 échec. Trois des cinq points restants étaient déjà faits : rate limiting, endpoint ApplicationInstance, quotas seed — détail au §« Restes non chiffrés » ci-dessus et dans v1-plan.md lot F.
⚠️ Ce qui reste du lot F n'est plus dans
manager-service: le bouton du portail et le diviseuraiTokensPerQuestiondansmanager-app(petit), et surtout les trois chantiersmymuseum-visitapp— déclenchement proactif (+ M3), miroir de la conversation vocale, émission des événements vocaux. Plus l'aperçu de conversation (L9), indépendant.
🎉 Les lots F et J sont clos le 2026-08-13 — il ne reste plus de développement V1
Tout ce qui suit a été livré dans la même journée, sur 5 repos.
dotnet test229 (213 passés, 16 sautés faute de démon Docker), APKdevdemymuseum-visitapp✅,flutter build webdemanager-app✅,npm run builddevisitapp-web✅,flutter analyze libsans erreur sur les deux apps Flutter.
Lot F — Stripe Customer Portal (backend + bouton), ratio jetons → questions mesuré, déclenchement proactif, M3, miroir de la conversation vocale, conversationId, émission des événements vocaux. L9 n'était pas un chantier : la « cible Assistant / Persona » du Mode preview est ce que le §5 du Guide IA a livré le 11/08 — un malentendu entre deux documents, pas du travail.
Lot J — table d'agrégats de thèmes, job de regroupement quotidien, interrupteur de collecte par instance (avec son UI), mention d'information dans les deux apps visiteur, purge du journal d'audit. D5 est livré avec eux.
Les cinq pièges de la journée, à ne pas re-découvrir :
- ⚠️ La garde du proactif recommandée par le plan aurait coûté de l'argent.
_triggerappelle le LLM avant de parler, et le?.sur l'orchestrateur avale le cas « aucun mode vocal actif » : un visiteur traversant une zone avec l'app en poche et le vocal éteint consommait des jetons facturés au client pour une phrase que personne n'entend. La garde interroge l'orchestrateur, pas le matériel. - ⚠️ Un écran peut mentir sans erreur. Sans état d'échec, une visite incomplète restait sur « téléchargement en cours » indéfiniment — le compteur n'étant incrémenté qu'en cas de succès, il n'atteignait jamais le total. Le bug d'origine (D5) était le
printavalé ; celui-là était pire et n'était écrit nulle part. - ⚠️ Le choix de voix du client n'était pas honoré. Le sélecteur Viva/Marco existait depuis le 07/08 et écrivait
GuideVoiceId; l'app visiteur ne lisait jamais ce champ. Même forme que W1. Et aucun APK ne parlait avec la voix du produit,GEMINI_API_KEYn'étant injectée nulle part — repli silencieux sur la voix système. - ⚠️ Trois écrans journalisaient de fausses questions de visiteur : le mode proactif (son prompt machine) et l'aperçu de conversation (le gestionnaire testant sa personnalité). Le drapeau s'appelle
IsVisitorQuestion, défauttrue— un client publié qui ne l'envoie pas continue de journaliser. - ⚠️ Le même code copié trois fois porte sa décision une seule fois.
_toLangCodelimitait le vocal à FR/NL/EN/DE dans les trois copies, mais ne le disait que dans une : les deux autres ressemblaient à un oubli à corriger. Idem pour les troisAssistantService, qui coupaient la conversation en morceaux.
Ce qui reste, et qui ne s'écrit pas en code : la relecture juridique du §8 des CGU, les conditions réelles de Google, le DPA séparé, et les traductions relues de la mention visiteurs (FR/NL/EN aujourd'hui, repli anglais assumé). Puis K6, K9, le lot H (tests, dont D0 sur device) et le lot I.
1. Le journal d'audit voyait tout sauf le contenu. AuditedTypes.Contains(entry.Entity.GetType()) exigeait l'égalité exacte de type, or Section est abstraite : le type runtime est toujours SectionMap, SectionQuiz… Aucune des 13 sortes de section n'était journalisée — soit précisément ce que l'écran du 12/08 devait tracer. Resource, Configuration, Device, User et Instance passaient, eux : ils sont concrets, et c'est ce qui rendait le trou invisible. Remplacé par une remontée à la classe de base auditée (IsInstanceOfType).
Tranché : normalisation côté serveur.
EntityTypeporteSection, pasSectionMap. Trois raisons : le filtre « Section » déjà présent dans l'écran se met à rendre des lignes sans touchermanager-app, donc sans coordonner deux repos ; élargir le filtre front aurait coûté 13 entrées de liste et 39 clés i18n et laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme ici ; et le sous-type concret n'est pas perdu, le discriminateur TPH est une propriété du modèle, donc sérialisée dansNewValues("Discriminator": "Article"). Un test le vérifie — ce n'était pas une supposition.
8 tests, dont un par réflexion qui affirme que les 13 sous-types concrets résolvent vers Section : la liste de types d'origine n'obligeait personne à la suivre, c'est ce qui l'a laissée devenir fausse.
2. Le plafond de 5 utilisateurs existe enfin côté serveur. CreateUser rend 422. Deux décisions, écrites dans le code : 5 en dur — le faire varier par plan serait une colonne sur SubscriptionPlan, donc une migration après le gel du lot B, dette V1 assumée ; et SuperAdmin non soumis au plafond, seule porte de service tant qu'aucun champ ne permet de relever la limite d'un client, ce qui s'aligne sur le front qui ne lui montre déjà pas le compteur. L'inscription self-service n'est pas concernée (premier utilisateur d'une instance neuve). Rien à retoucher côté front : le résultat d'invokeAPI est déjà lu depuis le correctif du 409.
3. GetSummary agrège en SQL au lieu de charger 13 mois en mémoire. Les six agrégats qui se lisent dans le JSON de Metadata ne remontent plus que deux colonnes, et seulement pour leur type d'événement. Effet de bord voulu : les stats avancées ne sont plus calculées puis effacées pour les plans qui n'y ont pas droit. Un changement de sémantique délibéré : « la langue de la session » était prise sur le premier événement rendu par la base, dans un ordre indéfini — c'est désormais le plus ancien horodatage.
⚠️ La branche « stats avancées » n'était couverte par aucun test : les cas existants ne seedent pas d'Instance, donc hasAdvancedStats était toujours faux et la moitié de la méthode n'était jamais exécutée. 11 tests ajoutés, plus 4 contre un vrai Postgres — le provider InMemory évalue tout côté client, il ne dit rien de la traduction SQL et une requête intraduisible y passerait au vert.
4. Le vector store est éprouvé à deux instances, sur un vrai Postgres. 14 tests Testcontainers sur l'image de Deployment/Dockerfile.postgres. Ils se sautent proprement (SkippableFact) sans démon Docker — vérifié : 186 passés / 14 sautés / 0 échec. Établi : pgvector 0.8.6 (donc SET hnsw.iterative_scan, posé à chaque recherche par SearchAsync, est accepté — sur une version antérieure toute recherche échouait en production) ; extensions vector et postgis bien créées par les migrations ; et à deux instances déséquilibrées 30 contre 1, SearchAsync rend le bon nombre de résultats, tous de la bonne instance, aucune fuite.
⚠️ Deux résultats contraires à ce que le plan supposait — à ne pas re-supposer à l'envers.
- Ce qui protège du post-filtrage n'est pas le parcours itératif, c'est l'index sur
(InstanceId, ContentType). Le planificateur filtre par instance d'abord et trie exactement : l'index HNSW n'est jamais touché, donc il n'y a rien à post-filtrer. Vérifié à 620 lignes (test) et à 22 000 hors suite, avec 2 000 lignes pour l'instance cible. Un test fige ce plan — si cet index disparaissait, la recherche se dégraderait sans qu'aucune erreur ne le dise.- Et cet index retiré,
hnsw.iterative_scan = relaxed_orderne rattrape rien : le parcours s'épuise après ~335 lignes (Rows Removed by Filterà l'EXPLAIN ANALYZE) sans atteindre l'instance minoritaire, et rend zéro. Le même jeu de données avec l'index HNSW construit après l'insertion rend bien ses 20 lignes : c'est la connectivité du graphe qui décide, et nos migrations créent l'index sur une table vide. Le SET est correct et sans coût, on le garde — mais il ne couvre pas ce qu'on croyait.
Outillage, pour ne pas le redécouvrir : Testcontainers.PostgreSql est épinglé en 3.10.0 — la 4.x parle l'API Docker 1.44 et l'engine local plafonne à 1.43. Et l'image est construite par le CLI docker, pas par le constructeur d'images de Testcontainers : celui-ci relit le FROM pour pré-tirer l'image de base et ne sait pas parser tag@sha256:. Ce digest protège la base d'un changement de glibc sous ses index — il ne se retire pas pour arranger un test.
5. Le journal ne suit plus les écritures de la machine — régression du point 1, corrigée le même jour. WeatherSyncService écrit section.WeatherResult sur cron, à 6 h et 13 h. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la prévision OpenWeather complète en avant ET en après — quelques dizaines de Ko, deux fois par jour, par section météo, indéfiniment. Le stockage est le moindre problème : ce bruit noie les modifications humaines que l'écran d'audit existe pour montrer.
Correctif : une liste de colonnes machine (WeatherResult, WeatherUpdatedDate, DateUpdate) exclues du journal, et aucune ligne produite quand une modification ne touche qu'elles — plutôt qu'une ligne au diff vide, qui aurait déplacé le bruit sans le retirer. DateUpdate y est pour une raison distincte de la météo : estampillé à chaque SaveChanges, il figurait dans tous les diffs sans jamais rien y apprendre. Le signal était déjà là — un test écrit le matin même devait l'écarter à la main (newValues.Keys.Where(k => k != "DateUpdate")) pour rester lisible. Quand un test doit filtrer une donnée pour être lisible, la donnée n'a rien à y faire. Vérifié au passage : AgendaSyncService écrit des EventAgenda, non audités, et ne touche pas la ligne Section — lui n'est pas concerné.
⚠️ Comment il a été trouvé, parce que la méthode compte plus que le bug : en cherchant à justifier un index sur
Timestampque j'avais signalé par réflexe. La question « pourquoi un index ? » a montré que ce qui faisait grossir la table n'était pas ce que je croyais.
⚠️ Deux dettes nouvelles, et une fausse alerte retirée (détail dans v1-plan.md lot F) :
AuditLogn'a aucune purge.VisitEventpurge à 13 mois,VisitorQuestionà 90 jours,AuditLogà jamais. C'est un sujet RGPD et rien d'autre — la table porteUserId, et les valeurs avant-après d'une modification deUsercontiennent e-mail, prénom et nom. Ce n'est pas un sujet de volume, le flot machine étant coupé. Donc à traiter au lot J. Recommandation : 12 mois, uniforme, avec le même verrou que les deux purges existantes — inerte tant qu'Audit:RetentionDaysn'est pas défini, le pg_dump quotidien n'étant toujours pas en place : supprimer des lignes d'audit sans restauration fine est la pire combinaison.- La bascule écrira ~2 700 lignes d'audit dans la transaction globale de
MigrationController(2 374 ressources + 307 sections + le reste), chacune avec un instantané JSON complet. Pas fatal, mais c'est neuf pour les sections et c'est la transaction qui ne doit pas échouer. À surveiller au prochain dry run. - ⛔
AuditLogsans index surTimestamp— fausse alerte, retirée. Signalée par réflexe sur « la table va grossir » ; elle allait grossir à cause de la météo. Le flot machine coupé, il reste les éditions humaines — de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients, qu'unORDER BY Timestamp DESC LIMIT 50trie en millisecondes sans index. Pas d'index, pas de migration, gel du lot B intact.
Lot C2 et C3 — livrés le 2026-08-12. Le lot médias est terminé côté serveur. dotnet build vert, dotnet test 163/163 (148 au départ, +7 pour C2, +8 pour C3).
C2 — backfill. POST /api/Resource/backfill-storage?dryRun=&instanceId=, SuperAdmin, dryRun à true par défaut : la migration se joue sur une base vide, ce backfill sur des lignes de production. Il rend son propre inventaire plutôt que de reprendre le « 37 lignes sur 45 » du plan, invérifiable d'ici — Orphans (aucune URL, blob peut-être jamais téléversé) et Unsized (URL présente, bucket muet) sont deux causes distinctes, jamais additionnées.
- ⚠️ La méthode annoncée était inapplicable. Le plan disait «
SizeBytespar listing du bucket Firebase » : le serveur n'avait aucun client de stockage. Le sondage passe par HEAD sur l'URL publique, comme la migration. - Le sondeur est extrait, pas recopié —
Helpers/ResourceSizeProbe.cs, consommé parMigrationControlleret le backfill. Même raisonnement que L5 pourResourceStorage: 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, sansContent-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. Corrigé des deux côtés ;MigrationControllerpeut donc remonter quelques lignes de rapport de plus qu'avant, sur des cas réellement muets.
C3 — quota autoritaire. Pré-vol sur les deux chemins de création, suppression du blob à Delete, angle mort d'Update tranché.
- ⚠️ Le pré-vol existait déjà à moitié, et ce n'était écrit nulle part.
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. Tout dépassement passait par la porte de service. - ⚠️ Les deux lectures du quota divergeaient.
Uploadlisait le quota du plan,GetQuotacelui 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. Fermé parHelpers/StorageQuota, désormais seule source de vérité pour les deux. Deletesupprime le blob AVANT la ligne, et un échec renvoie 502 en conservant la ligne. Le choix se résume ainsi : une ressource encore listée se rattrape, un blob dont plus aucune ligne ne porte le chemin est facturé sans que rien ne le désigne. Non configuré, le service renvoieNotConfigured— il ne fait jamais semblant d'avoir nettoyé.- ✅ L'angle mort laissé ouvert par C1 était une fausse crainte. On redoutait qu'un recalcul de chemin « pointe vers un objet qui n'a pas bougé ». Or
PathForne construit qu'unpictures/{instanceId}/{resourceId}: le type n'entre pas dans le chemin, il décide seulement s'il y en a un. Recalculer ne peut pas pointer ailleurs —Updaterejoue doncApply.
Aucun secret nouveau, contrairement à ce qu'on croyait en tranchant. FirebaseAdmin était déjà référencé pour les notifications push et Startup charge déjà un service account via Firebase:CredentialsPath : Google.Cloud.Storage.V1 réutilise le même GoogleCredential. Seule s'ajoute la clé Firebase:StorageBucket, vide par défaut — donc à renseigner en prod (I9), sans quoi Delete ne supprime rien.
⚠️ Deux suites à ne pas perdre : manager-app supprime toujours le blob de son côté (show_resource_popup.dart:130-137), après coup et en avalant l'échec — c'est devenu du code mort depuis C3, à retirer (C6), mais pas avant que Firebase:StorageBucket soit posé. Et dotnet restore échoue en 401 si la source NuGet git.dev-espaces-naturels.lu est déclarée dans l'image Docker : elle n'a rien à voir avec ce projet, et Google.Cloud.Storage.V1 est le premier paquet ajouté depuis longtemps — le prochain build d'image la rencontrera.
Lot C1 — livré le 2026-08-11. Calculateur StoragePath/SizeBytes extrait dans Helpers/ResourceStorage.cs, avec 13 tests (dotnet test 143/143). Il ferme le lien L5 : C2 (backfill) et l'écart (e) du lot G appelleront le même code au lieu d'en écrire deux.
- ⚠️ L'extraction a prouvé la divergence qu'elle devait empêcher.
ResourceControllera deux chemins de création : le chemin multipart écrivaitSizeBytesmais laissaitStoragePathnul, seul le chemin JSON écrivait les deux. Le §1quater annonçait «StoragePath/SizeBytesécrits àCreatedepuis le 10/08 » — c'était vrai d'un chemin sur deux. Une partie des lignes à rattraper par C2 vient de là. - Angle mort laissé ouvert sciemment :
Updatepeut faire passerTyped'un type URL à un type fichier, laissantStoragePathnul sur une ligne qui a désormais un blob. Recalculer le chemin sur une ressource existante pointerait vers un objet qui n'a pas bougé dans le bucket — à trancher avec C3 (quota).
Lot B — livré le 2026-08-11. Le schéma est gelé, le lot G est débloqué. Une seule migration EF (20260811130738_LotB_FreezeSchema) : rename SectionMap.MapResourceId → IconResourceId (l'écart (g) du §1quinquies tombe avec), suppression de SectionEvent.ParcoursIds, et Instance.IsImageWatermark remplaçant le instanceId == "633ee379…" en dur de ResourceController. 130/130 tests avant comme après, build Debug et Release verts.
- ⚠️ Le rename imposait aussi la propriété de navigation
MapResource→IconResource: la convention EF l'appariait au FK, la laisser en place aurait fabriqué un FK fantôme. Elle n'était utilisée nulle part ailleurs. - ✅ Point de risque levé par vérification : EF a généré un
RenameColumn, pas un drop+add — les icônes déjà configurées survivent. L'avertissement « may result in the loss of data » ne porte que sur leDropColumndeParcoursIds, qui est l'intention du lot. - ⛔ « Supprimer
SectionEvent.IconResourceId» était une erreur de doc — ne pas rouvrir.SectionEventn'a pas ce champ : la ligne visée appartient à la classe imbriquéeMapAnnotation, partagée par SectionEvent, SectionAgenda et SectionMap, et lue par cinq contrôleurs plus les deuxSelectManydeGetReferencedResourceIds. La supprimer aurait cassé les icônes d'annotation des trois types de section et la collecte offline. - Sécurité rapide du lot A, dans la même passe : le « backdoor
#if DEBUG» était un bloc deAuthenticationController.Authenticatequi écrasait l'email et le mot de passe reçus partest@email.be— toute compilation Debug authentifiait n'importe quelle saisie. Retiré.EnableSensitiveDataLoggingpasse sous#if DEBUG(idiome déjà utilisé dansStartup.cspour CORS et Hangfire ; le Dockerfile publiant en-c Release, c'est un verrou réel). - Reste du volet sécurité, chiffré et non fait : 112 retours
new ObjectResult(ex.Message) { StatusCode = 500 }exposent le message d'exception interne, répartis sur 17 contrôleurs. Les 136 autresex.Messagesont des 400/404 volontaires, à garder.Startup.UseExceptionHandler(HandleError)existe mais ne traite queRequestException. C'est un chantier à part, pas une retouche.
Lot E — livré le 2026-08-11. PDF en média d'étape (les trois volets : liste de types paramétrable, ResourceViewer.tsx, CachedCustomResource), comparaisons QuestionType par entier brut, et W1 tranché : le web reste sur Leaflet (option a — ni MapBox ni Google Maps JS, donc aucun coût récurrent ajouté avant la prod).
⚠️ W1 ne pouvait pas se faire comme l'option le disait. « Retirer le champ pour les instances web » est impossible : le fournisseur est porté par la section — SectionMap.MapMapProvider et SectionAgenda.AgendaMapProvider — pas par l'instance. Et une même Configuration est liée à plusieurs ApplicationInstance via AppConfigurationLink, donc servie au mobile comme au web : masquer le champ aurait supprimé le réglage mobile. Le champ est donc conservé, et manager-app cesse de laisser croire qu'il vaut pour le web (mention sous les deux sélecteurs). Côté visitapp-web, zéro ligne : Leaflet inconditionnel était déjà le comportement.
⚠️ Piège de doc corrigé au passage : le plan disait « cas PDF dans getElementForResource ». Cette fonction de mymuseum-visitapp fait un return CachedCustomResource(...) avant son switch — tout son switch est du code mort. Le vrai dispatcher est CachedCustomResource, et il en a deux (ressource distante / fichier local de l'offline). Suivre la doc à la lettre aurait produit un correctif inopérant et « vérifié » par une analyse verte.
Add-on IA sur un plan sans IA — mode d'emploi et correctifs (2026-08-12)
Le mécanisme tient sans code. ApplyPlanQuotas ne repart du plan que si SubscriptionPlanId change (InstanceController:227) : surcharger Instance.AiTokensPerMonth sur une instance Pro survit, et le compteur mensuel se réarme seul sur AiUsageMonthKey. C'est ce qui permet de donner l'IA à MDLF et au Fort Saint-Héribert sans les passer en Premium — nécessaire pour éprouver le post-filtrage HNSW sur 4 instances plutôt que 2 (L14).
Mais l'activation est une manip, pas un geste produit. AiTokensPerMonth n'est exposé dans aucun DTO — UpdateInstance ne le lit jamais. Trois gestes, dans cet ordre :
UPDATE "Instances" SET "AiTokensPerMonth" = …, "IsAssistant" = true WHERE "Id" = '…'IsAssistant = truesur lesApplicationInstancedu client — garde séparée (AiController:360), celle qu'on oublie : l'instance est dotée, le chat refuse quand même- Le bouton de relance d'indexation de l'écran Guide IA (
POST /api/Ai/reindex/{id}, SuperAdmin) — obligatoire : le rattrapage automatique est câblé dansUpdateInstanceen C# (:234), un UPDATE SQL ne le déclenche pas, et le client paierait un guide qui ignore tout de son contenu déjà saisi
⚠️ Changer son plan plus tard écrase la surcharge et le remet à 0 jeton sans rien signaler. Pro → Premium est sans risque (Premium donne plus) ; tout autre mouvement casse l'add-on en silence.
⚠️ Aucune valeur d'add-on n'est arrêtée. Premium est à 20 M. Tant qu'un chiffre n'est pas fixé, deux clients payant la même chose n'auront pas le même quota.
Facturation : rien de neuf. StripeService ne connaît qu'EssentielPriceId (:56) — Pro, Premium et Enterprise sont déjà facturés hors Stripe, à la main. L'add-on rejoint un processus existant, il n'ajoute pas de dette.
Deux correctifs livrés le 2026-08-12 — dotnet build vert, dotnet test 148/148 :
BackfillInstanceAsyncmet en file un job par section au lieu de les traiter en boucle. L'unité de reprise devient la section : avec[AutomaticRetry(Attempts = 2)]posé sur la classe, un échec d'embedding à la 280ᵉ section faisait retenter le job entier, donc ré-embedder les 279 déjà indexées, deux fois — des appels facturés pour rien et autant de chances de retomber sur la limite de débit. Le chemin de backfill cesse d'être un chemin à part : c'est celui de l'intercepteur. Effet de bord utile : l'avancement est visible section par section dans/hangfire.IngestSectionCountingAsyncdisparaît, sonintne servait plus.- Backoff sur 429/503 dans
GoogleEmbeddingService: 3 tentatives,Retry-Afters'il est fourni, exponentiel sinon. Absorbe la rafale courte sans jamais remonter jusqu'à Hangfire.
📅 Écran SuperAdmin d'activation — reporté en V2, décidé le 2026-08-12. Exposer
AiTokensPerMonthdansInstanceDTOferait passer les trois gestes ci-dessus à un seul, et le backfill repartirait tout seul puisqueUpdateInstancele déclenche déjà sur la transition0 → >0. Mais la V1 n'a besoin d'aucun add-on : les 4 clients de la bascule sont affectés à leur plan (§1quinquies, plans), et doter une instance à la main reste un geste interne, fait une fois, sur une base qu'on contrôle. Ce qui rend le report tenable, c'est que la procédure SQL ci-dessus est écrite : elle ne se redécouvre pas.Ce qui ferait remonter ce chantier : vendre l'add-on à un client réel avant la bascule. Une manip SQL sur une instance en production qui tourne n'a pas le même coût que sur une base de dev, et c'est là que le formulaire devient le moyen sûr.
À faire d'un bloc, le jour venu, avec l'endpoint de mise à jour d'
ApplicationInstance(canaux activables) — c'est le même écran. ⚠️ Mais seule la partie add-on part en V2 : le volet canaux reste au lot F, il est dans les restes non chiffrés de la V1.
Contrôle du code du 2026-08-11 (2e passe) — ce qui a bougé depuis la rédaction du plan
Tout vérifié dans le code, pas dans les docs. Deux points du lot A tombent, un point du lot A est différé, un nouveau suspect apparaît.
| Point | Ce que disaient les docs (matin du 11/08) | Ce que dit le code (soir du 11/08) |
|---|---|---|
| Lot A étape 1 — committer | « 44 fichiers modifiés dont 9 non suivis, prérequis absolu » | ✅ Fait. 9 repos propres et synchronisés avec leur remote — DOCS/ en est un, ce qu'on oublie en énumérant les repos de code. Voir §4 |
| Lot A — rotation des secrets | Verrou L1, ouvre le lot A | 📅 Différée en fin de backlog, juste avant I3, à la demande du 2026-08-11 — repos privés auto-hébergés. Reste le verrou de I3, ne disparaît pas. Voir §3 |
Lot A — nettoyage manager_api_new |
« 14 fichiers modèle orphelins » | ✅ Diagnostic fermé : 139 part déclarés pour 153 fichiers, les 14 sont hors graphe de compilation → suppression sans effet possible sur un build. Plus openApiTest.dart, qui est le déclencheur de génération. Voir §3 |
| Lot A — build tablet-app | « échec Gradle, seul build cassé » | ❓ commit 5a6701d (« drapeaux du migrateur Flutter ») a peut-être réglé le Gradle, à reconfirmer par un vrai build. Le problème reste purement Gradle : une piste Dart a été ouverte puis écartée le même jour — marker_view.dart:456 référence bien ContentGeoPoint (classe hors graphe), mais la ligne est dans un bloc commenté, comme sa jumelle de mymuseum-visitapp:367. flutter analyze sur les deux fichiers : zéro erreur. Ne pas rouvrir cette piste |
| DB3 — écran Guide IA | « 🔨 front livré » | ✅ Confirmé livré des deux côtés. Onglets par boutons custom (guide_ia_screen.dart:249-265) — pas un TabBar, ce qui explique pourquoi le grep du plan concluait « aucun onglet » : la coquille existe bien. Backend : AiController knowledge/{instanceId}:190 et insights/{instanceId}:232, VisitorQuestionPurgeService.cs présent |
| Onglet « Vocal » des stats | « reste à faire » | ✅ Confirmé absent. statsChannelVoice n'est qu'un libellé de canal (statistics_screen.dart:189), pas un onglet |
| Lot B — les 4 changements de modèle | « rien fait » | ✅ Confirmé, aucun n'a bougé : SectionEvent.ParcoursIds:28, SectionMap.MapResourceId:25, hardcode watermark ResourceController.cs:265, et MeterZoneGPS sans aucun usage visiteur (zéro occurrence dans mymuseum-visitapp/lib hors swagger, zéro dans visitapp-web/src) — le bug M3 est réel |
| Lot C1 — calculateur extrait | « à faire » | ✅ Confirmé à faire : StoragePath n'apparaît que dans ResourceController et Resource.cs, aucun service |
| Lot D1 | « méthode existe, appelée nulle part » | ✅ Confirmé : hors des 13 sous-types, GetReferencedResourceIds n'apparaît que dans sa déclaration abstraite (Section.cs:85) et un test |
RGPD reporté en fin de backlog le 2026-08-11 (lot J) — table d'agrégats de thèmes, job de regroupement, interrupteur de collecte par instance, mention affichée aux visiteurs, relecture juridique du §8 des CGU, vérification des conditions Google. C'est tenable parce que la porte « rien en prod avant la fin du backlog » tient : la journalisation ne tourne d'ici là que sur des bases de développement, aucun visiteur réel n'est concerné. Si un déploiement anticipé était décidé, ce lot redeviendrait bloquant.
Le lot B n'existait nulle part : tout changement de modèle encore ouvert (rename MapResourceId, arbitrage ParcoursIds, unification QuestionType, flag watermark) doit passer avant la réécriture de BuildSection, sinon on écrit le mapping deux fois — et après la bascule, c'est une migration de données sur la prod.
Lot D-bis — les trois écrans de design
Constat du 2026-08-11, plus ouvert que ce que ce fichier annonçait : l'écran Guide IA « livré le 07/08 » était 5 cartes en colonne unique sans onglets, les stats de l'assistant étaient à zéro, et la refonte SectionParcours n'avait pas commencé.
Cause identifiée : les maquettes vivaient dans des artifacts d'un autre compte, illisibles depuis la machine de dev — chaque session redessinait. Réglé : le HTML est rapatrié dans claude design/ (guide-ia-screen.html, statistics-screen.html, sectionparcours-refonte-flux.html). Même découpage que le kanban — contenu dans le repo, diffusion par artifact.
| Étape | État |
|---|---|
| DB0 — rapatrier les maquettes | ✅ 2026-08-11 |
DB1 — socle visuel constants.dart |
✅ 2026-08-11 — 11 rôles typographiques, 8 espacements, 5 rayons. Échelle des maquettes, couleurs de l'app : un écran neuf se fond dans manager-app, il n'y ouvre pas une seconde palette. Pas de thème sombre |
| DB2 — sauvegarde au fil de l'eau SectionParcours | ✅ 2026-08-11, puis retiré le même jour par DB4. Le garde-fou (confirmation d'abandon sur les trois dialogues + PopScope) a fermé la perte silencieuse le temps que la persistance arrive. Les trois dialogues n'existent plus et il n'y a plus de travail non enregistré à perdre : les 3 clés i18n sont supprimées. C'était l'issue prévue par l'arbitrage — les deux étaient alternatives, pas cumulables |
| DB3 — écran Guide IA 2 onglets | 🔨 2026-08-11 — coquille à onglets, aperçu de conversation sur le vrai /api/AI/chat, onglet « Ce que demandent vos visiteurs » alimenté par GET /api/Ai/insights/{id}, carte « Ce que connaît votre guide » sur GET /api/Ai/knowledge/{id}, journalisation VisitorQuestion branchée dans AiController.Chat. 34 clés i18n FR/EN/NL. Reste : le job de regroupement en thèmes, la purge 90 j, le RGPD dans les CGU avant mise en service, l'onglet « Vocal » des stats |
| DB4 — forme SectionParcours | 🔨 2026-08-11 — option A livrée : une fenêtre, un rail d'étapes, profondeur 2. Les trois showNewOrUpdate… sont supprimés au profit de guided_path_editor.dart (coquille : fil d'Ariane, rail réordonnable, panneau, pied « Enregistré ») et de trois widgets de champs autonomes — ParcoursFields, EtapeFields, QuestionFields dans Parcours/Fields/. Une question se déplie dans le panneau de l'étape, plus dans une 4ᵉ fenêtre.Sauvegarde à la saisie : guided_path_api.dart masque le choix sectionParcoursApi / sectionMapApi, un débounce de 700 ms écrit le parcours (PUT), chaque étape (POST/PUT/DELETE) et ses questions avec elle. Le parcours est créé à la première modification, pas à l'ouverture — fermer une fenêtre neuve sans rien saisir ne laisse rien en base. Deux pièges du backend : UpdateGuidedPath supprime les étapes absentes du DTO, donc le payload n'emporte que celles qui ont déjà un id (les autres attendent leur CreateGuidedStep et feraient un doublon) ; et les questions n'ayant pas d'endpoint, leur id entier est récupéré après coup par order, seul repère stable entre les deux listes. Échec d'écriture : la fenêtre refuse de se fermer et le pied de page porte un « Réessayer ». 14 clés i18n FR/EN/NL, 3 retirées (DB2). flutter analyze propre sur le dossier, flutter build web ✅.Reste : DB5, la vérification à l'œil contre la maquette |
AIApi était dans le client généré mais exposé nulle part dans client.dart — les 18 autres façades y sont. Câblé le 2026-08-11.
Arbitrages tranchés le 2026-08-11 — le lot B s'allège :
SectionEvent.ParcoursIds→ supprimer. Le champ n'est lu ni écrit nulle part (entité, DTO, migrations — zéro contrôleur, zéro service), et son commentaire dit// Liens vers GeoPoints spécifiques, ce qui contredit son nom. Le lien événement↔parcours existe dans l'autre sens,GuidedPath.SectionEventId, lui bien entretenu. Décisif :ParcoursIdsest porté parSectionEvent, pas parProgrammeBlock— il ne peut donc pas exprimer « les parcours de tel jour », précisément le cas carnaval invoqué pour le garder.QuestionType→ on ne touche pas au schéma, et il sort du lot B. Le trio TextLibre/Digicode/ExpectedAnswer n'existe pas dans le code : l'enum vautSimple/MultipleChoice/Puzzle, référencé à un seul endroit côté serveur, et le digicode est un comportement dérivé d'une réponse numérique, pas un type. Reste une propreté front (comparaisons par entier brut dansshowNewOrUpdateGuidedStep.dart:409etguided_step_challenge.dart:14), déplacée au lot E.MigrationControllern'est plus bloqué que par un seul changement de modèle.
1quinquies. La bascule elle-même — Mongo → Postgres en prod (ajouté le 2026-08-09)
Trou identifié le 2026-08-09 : le §1ter dit ce qui doit entrer dans le schéma avant que la base se remplisse. Il ne dit nulle part comment on remplit la base. L'opération de bascule n'était écrite ni ici, ni dans le kanban, ni dans aucun plan de
v2/.Rappel : la prod tourne encore sur MongoDB. Le transfert se fait par
MigrationController(POST /api/migration/run, paramètresdryRunetinstanceId), qui rejoue Instances → Resources → Users → Configurations → ApplicationInstances → Sections → Devices, puis relie les menus et enrichit les ressources PDF.
⚠️ MigrationController a pris du retard sur le modèle
Écrit avant l'essentiel des chantiers de 2026 (SectionEvent, SectionParcours, abonnements, onboarding, guide IA, colonnes médias). Relevé le 2026-08-09 sur ManagerService/Controllers/MigrationController.cs :
| # | Écart | Où | Conséquence si on bascule tel quel |
|---|---|---|---|
| a | BuildSection ne couvre que 11 types (Map, Slider, Video, Web, Menu, Quiz, Article, PDF, Game, Agenda, Weather) |
:458-613 |
SectionEvent et SectionParcours tombent dans le default → ligne d'erreur dans le rapport, section non migrée, silencieusement |
| b | Les collections filles ne sont jamais remplies : QuizQuestions = new(), EventAgendas = new(), aucun GuidedPath/GuidedStep |
:526, :591 |
Un quiz arrive sans ses questions, un agenda sans ses événements |
| c | Instance : 4 champs migrés sur ~35 |
:106-112 |
SubscriptionPlanId null → instance sans plan (quotas à 0, HasStats false) ; PublicApiKey null → les apps visiteur ne s'authentifient plus (X-Api-Key) ; WebSlug null → visitapp-web injoignable ; IsMobile/IsTablet/IsWeb/IsAssistant à false |
| d | Role = UserRole.ContentEditor en dur |
:225 |
Plus aucun administrateur après la bascule |
| e | Resource : StoragePath, FileName, IncludeInAiKnowledge, AiIndexStatus non renseignés ; SizeBytes déduit d'un HEAD sur l'URL Firebase, échec avalé → 0 |
:172-181 |
C'est le point 5 du §1ter (backfill) : à faire dans la migration, pas après |
| f | IsQRCode / IsSearchText / IsSearchNumber forcés à false |
:276-278 |
Réglages de configuration perdus |
| g | MapResourceId = dto?.iconResourceId |
:469 |
À répercuter le jour où la colonne est renommée en IconResourceId (§6bis) |
| h | Pas de transaction globale : 9 étapes, un SaveChangesAsync chacune, catch qui renvoie 200 OK avec FatalError dans le corps |
:68-86 |
Un échec au milieu laisse la base à moitié remplie. La reprise ne tient qu'aux tests AnyAsync d'idempotence — qui existent, eux, et sont corrects |
Le point c est le plus dangereux : il ne produit aucune erreur. La migration se déclare réussie et le lieu est inaccessible aux visiteurs.
La bascule, dans l'ordre
Numérotation continue avec le §1ter (1-13).
| # | Quoi | Note |
|---|---|---|
| 14 | Remettre MigrationController à niveau — les 8 écarts a→h ci-dessus |
Le plus gros morceau. À faire avant tout le reste |
| 15 | Dry run sur un export Mongo de prod, puis comparer les comptages par entité aux comptages Mongo | dryRun=true n'écrit rien. Un export existe déjà dans manager-service/migration-data/ |
| 16 | Créer l'environnement prod Postgres : Deployment/Dockerfile.postgres (épinglé par digest), compose prod, Traefik, port 5432 fermé |
Dépend de la rotation des secrets (§3) — ne pas créer la prod avec les clés committées |
| 17 | dotnet ef database update sur la base vide, puis vérifier postgis et vector présents |
Le §1quater rappelle qu'une base en retard d'une migration fait planter l'API dès le login |
| 18 | pg_dump avant et après, puis cron quotidien |
Déjà noté en §4 comme non fait. Ici il devient bloquant : c'est le seul filet du jour J |
| 19 | Rejouer pour de vrai, instance par instance (instanceId=…), en commençant par la plus petite |
Le paramètre existe précisément pour ça |
| 20 | Vérifications post-bascule : login manager-app, une app visiteur avec sa clé API, un parcours, une carte, un PDF, un quiz avec ses questions | Les points b et c ne se voient qu'ici |
| 21 | Bascule DNS / API, Mongo gardé en lecture seule quelques jours | |
| 22 | Plan de retour arrière écrit avant le jour J | Un rollback qu'on improvise à 23 h n'est pas un rollback |
Après la bascule — relationnel, pas technique
| Quoi | Note |
|---|---|
| Reprendre contact avec Louise Smets — Musée d'Ixelles | À faire une fois la prod stabilisée, pas avant : on n'ouvre pas une conversation en montrant un produit en cours de bascule. Objectif d'abord d'écoute — comprendre ce qu'est réellement son travail au quotidien et sur quoi elle bute — avant de parler de MyInfoMate. L'entraide se décide après, pas dans le premier message. Rien n'est su de son rôle exact à ce stade : à ne pas supposer. |
Rappel de la porte : rien de tout ça ne démarre avant que le reste du backlog soit terminé (§1quater). La base Postgres vide est une fenêtre — chaque chantier de schéma passé maintenant est un chantier qui n'exigera pas de fenêtre de maintenance plus tard.
2. Tests
→ Détail complet : test-plan.md
Checklist manuelle organisée en 20 sections + non-régression. À cocher au fur et à mesure avant une mise en prod. Section "Hors scope" = features non implémentées, pas testées.
Ordre conseillé : 0. §21 — Visite hors ligne (ajouté le 2026-08-07) : bugs suspectés d'après le code, à confirmer sur device avant de planifier le chantier
- §0 — Porte d'entrée build (5 commandes, bloquant absolu)
- §19 — Parité manager-app → apps visiteur, par type de section : c'est le chapitre qui répond à « tout ce que je configure est-il visible ? ». Le §19.13 (SectionParcours, 5 cas d'usage) est le plus à risque.
- §18 — Onboarding self-service (jamais exécuté)
- §1-17 — features par lot, §16 non-régression
2bis. Parité manager-app → apps visiteur
→ parity-manager-visitapp.md (audit du 2026-08-05, sur le code réel)
- Couverture par type de section : complète — les 13
SectionTypesont dispatchés dansmymuseum-visitappetvisitapp-web, aucun écran vide. - 17 écarts au niveau champ : 7 côté mobile (dont le centrage de carte, les horaires des points d'intérêt, et surtout le mode contenu d'un parcours sans géodéclenchement), 10 côté web (dont le provider de carte, les icônes de catégorie, et l'absence totale d'assistant IA).
- Suivi des corrections : table du test-plan.md §20.
✅ Résolu le 2026-08-06 — assistant IA dans visitapp-web (écart W8)
L'assistant n'existait que sur mobile, alors que le plan Essentiel est web-only et que l'IA est vendue en add-on. components/assistant/AssistantBubble.tsx : bulle flottante montée par le layout de configuration, uniquement si isAssistant est vrai sur l'instance et sur l'app Web (même règle que AiController). Consomme POST /api/AI/chat en AppType.Web — aucun changement backend.
- Rend les trois formes de réponse du modèle : texte HTML,
cards[], et l'actionnavigationqui route vers la section. - Suggestions d'ouverture dérivées du contenu réel (
lib/assistantSuggestions.ts, 9 tests) : les sections déjà chargées donnent les questions proposées — un lieu sans agenda n'en propose pas sur l'agenda, et la carte cite un point réel. Zéro configuration client, zéro token. expectsReplysert uniquement à ne pas réarmer la dictée quand le modèle clôt l'échange — pas au focus du champ, où il n'apporterait rien.- Quota atteint (429) → message neutre, le visiteur ne lit jamais le motif technique.
- Maquette de référence : artifact « Assistant de visite — maquette visitapp-web ».
Répercuté sur mymuseum-visitapp le 2026-08-06 — l'assistant mobile n'était pas aligné :
- Quota IA (429) traité :
AssistantChatSheetaffichait « Une erreur est survenue, réessayez » pour toute erreur, y compris le quota épuisé — le visiteur était donc invité à boucler sur un mur. NouvelleAssistantUnavailableExceptionlevée parassistantService, message neutre côté sheet. - Suggestions d'ouverture ajoutées (
Helpers/assistantSuggestions.dart), dérivées deVisitAppContext.currentSectionsdéjà en mémoire. Même liste de phrases que le web — garder les deux fichiers alignés. - Forme alignée sur la maquette : pastille ronde pleine + « Votre guide » + nom du lieu en en-tête ; trois points animés au lieu du
CircularProgressIndicator+ « ... » ; bandeau « Je vous écoute » avec onde pendant la dictée. Le pattern reste une modale bottom sheet (showGeneralDialog+ voile) — c'est le bon geste sur mobile, la bulle flottante du web n'aurait pas de sens sur 375 px.
✅ Résolu le 2026-08-06 — refonte de la configuration des parcours (écarts M4, M7, M8, W6, W7, X1)
Le client ne manipule plus 9 booléens répartis sur 3 niveaux, mais répond à 3 questions :
| Question | Où | Ce qu'elle pilote |
|---|---|---|
| Où se déroule le parcours ? — En salle / Sur le terrain | SectionParcours | ShowMap. « En salle » masque automatiquement position et zone sur toutes les étapes |
| Quelle ambiance ? — Visite / Jeu | GuidedPath | IsGameMode + messages de début/fin |
| Comment le visiteur progresse-t-il ? — Libre / Dans l'ordre / Étape par étape (+ masquer les étapes non atteintes) | GuidedPath | IsLinear, RequireSuccessToAdvance, HideNextStepsUntilComplete — dérivés dans Parcours/progression_mode.dart |
IsStepLocked,IsHiddenInitially,FactContentsupprimés du modèle (migrationRemoveDeadGuidedStepFlags).IsStepLockedrendait une étape définitivement infranchissable même après réussite du défi.- Navigation libre réellement implémentée (pins et segments de progression cliquables), verrouillage dérivé de la progression, masquage appliqué aussi en mode contenu — mobile et web.
- Bascule carte/contenu unifiée sur
ShowMapseul dans les deux apps. - Note ajoutée dans l'éditeur de question : une réponse attendue uniquement numérique déclenche automatiquement le pavé digicode côté visiteur.
Reprise du 2026-08-09 — 4 correctifs sur ce même écran (relecture après coup, à valider via test-plan §19.13 cas 0)
- La progression enregistrée ne correspondait pas à celle affichée. Un
GuidedPathDTOneuf naissait avecisLinearnul :progressionModeOfaffichait « Dans l'ordre », maisisLinear ??= falseà la sauvegarde enregistrait « Libre ». Tout parcours créé sans toucher à la question partait donc en mode libre. Les valeurs sont désormais posées à la construction. Le testprogression_mode_test.dart(12/12) ne pouvait pas l'attraper : il couvre la lecture/écriture des booléens, pas le DTO fabriqué par la popup. - « Quelle ambiance ? » était restée une case à cocher alors que les deux autres questions sont des radios — remise en Visite / Jeu, conforme au tableau ci-dessus.
- Médias d'étape rendus en dur comme des images dans les deux apps visiteur (
guided_path_content_progression_page.dartforçaitResourceType.Image,StepCarouselun<img>). Le sélecteur accepte pourtant vidéo et audio depuis toujours : ces deux types donnaient une vignette cassée. Corrigé des deux côtés ; le libellé « Images » devient « Médias ». Le PDF, lui, reste à faire — voir todo-features.md. - Menu « Carte de base » : disparaissait en silence quand la configuration n'avait aucune SectionMap ; il reste maintenant visible et désactivé, avec l'explication.
Reste à trancher — la forme, pas le vocabulaire. Configurer une question sur une étape et l'écrire en NL traverse 5 surfaces empilées. Deux options maquettées le 2026-08-09 : artifact « Configurer un parcours sans empiler quatre fenêtres » — A, une fenêtre unique avec rail d'étapes (recommandée) ; B, écran plein cadre à 3 colonnes. Rien ne se code avant l'arbitrage ; détail et ordre d'exécution dans todo-features.md § « SectionParcours — refonte du flux de configuration ».
3. Sécurité & dette technique
→ Détail complet : security/audit-securite-manager-service.md et security/audit-manager-app.md (audit du 2026-07-13/14)
manager-service — reste à traiter (par priorité)
- Critique en exposition, différée en calendrier — Secrets committés en clair dans Git (JWT signing key, clés API, connection strings, mot de passe MQTT, token Telegram) → rotation + variables d'environnement + purge historique Git + suppression
RELEASE/- 📅 Déplacée en fin de backlog le 2026-08-11, à la demande. Motif : les 6 repos sont privés, sur un serveur Gitea auto-hébergé — l'exposition réelle est celle d'un poste de dev, pas d'un dépôt public. La gravité ne change pas, la fenêtre d'urgence oui.
- ⚠️ Ce que ça déplace dans le plan : la rotation était le verrou L1 du lot A (« ne pas créer la prod avec les clés committées »). Elle reste ce verrou, mais juste avant le lot I au lieu d'ouvrir le lot A. La contrainte à ne pas perdre : elle doit précéder I3 (compose/Traefik prod), sinon la prod naît avec les clés de l'historique et il faut rotationner deux fois.
- 🔒 Condition de validité : tenable tant que les repos restent privés et qu'aucun collaborateur externe n'est ajouté. Un repo rendu public, un fork, ou un CI tiers qui clone l'historique → la rotation remonte devant immédiatement.
- Haute —
EnableSensitiveDataLoggingactif en prod ; services Mongo legacy en singleton à corriger ;ValueComparermanquant sur les JSONB ;✅ corrigé le 2026-08-07 (AssistantServicenew HttpClient()en boucleIHttpClientFactory, 4 occurrences) - Moyenne — JWT sans expiration forcée, clés API en clair + comparaison non constant-time, bootstrap PIN sans rate limiting, exceptions internes exposées, backdoor
#if DEBUG, doubleSaveChangesAsyncaudit (⚠️ vérifier après le fix du 2026-07-15 surSaveChanges/SaveChangesAsync— peut être partiellement résolu),SectionFactorydupliqué, tests EF InMemory peu représentatifs - Basse — conventions REST hétérogènes,
PasswordUtilsavec RNG non crypto, code mort (MQTT, CORS AllowAll), CORS en dur, ordre middleware
manager-app — reste à traiter (par priorité)
- ✅ Critique — résolu le 2026-08-11. Client généré désynchronisé (
manager_api_new/) : 14 fichiers modèle orphelins →flutter analyzeinexploitable (183/200 erreurs de bruit, 68 au 09/08). Mesuré après nettoyage :flutter analyze manager_api_newpasse de 67 à 3 issues, l'analyse globale de manager-app de 68 erreurs de bruit à 3 erreurs, toutes danstest/widget_test.dart(cassé et connu, voir « Basse » ci-dessous).flutter build web✅ avant comme après — attendu, ces fichiers n'étant dans aucun graphe.flutter analyzeredevient un feu vert utilisable, ce qui lève le lien L8- Caractérisé précisément le 2026-08-11.
lib/api.dartdéclare 139partpour 153 fichiers danslib/model/. Les 14 restants portent bienpart of openapi.api;mais ne sont déclaréspartpar personne — en Dart, un fichierpartnon listé par sa bibliothèque est hors du graphe de compilation. D'où l'asymétrie qui a coûté du temps : l'analyzer les lit (il parcourt toutlib/), le compilateur ne les voit jamais. Les supprimer ne peut pas changer un build. - Les 14 :
agenda_event_stat_dto,app_configuration_link_dto_application_instance,content_dto_resource,content_geo_point,day_stat_dto,game_stat_dto,level_dto,map_dto_map_provider,map_dto_map_type,map_dto_map_type_mapbox,poi_stat_dto,quiz_stat_dto,section_stat_dto,user. - Fausse alerte levée le 2026-08-11, à ne pas rouvrir :
ContentGeoPoint(un des 14) semble référencé par du code vivant —tablet-app/lib/Screens/Map/marker_view.dart:456etmymuseum-visitapp/lib/Screens/Sections/Map/marker_view.dart:367, les deux apps dépendant demanager_api_newparpath:. Les deux lignes sont dans un bloc commenté (/*ouvert respectivement en 418 et 329) : du code mort, invisible pour le compilateur et pour l'analyzer. Vérifié par comptage de délimiteurs et confirmé parflutter analyzesur les deux fichiers — zéro erreur. Aucun des 14 n'a de référence vivante. - 🔒 Les déclencheurs de génération partent avec eux, et pour une autre raison : ce n'est pas du modèle mort, c'est le moyen de régénérer (annotation
@Openapi+build_runner). Importés nulle part. Les supprimer ne retire aucun code exécuté — ça verrouille la règle «manager_api_news'édite à la main » : tant qu'ils sont là, unflutter pub run build_runner buildlancé par réflexe régénère le client et écrase les éditions manuelles (onboarding_api.dart, le câblage d'AIApi, le mappingisGood→isCorrect). - ⚠️ Ils étaient trois, pas un — trouvé le 2026-08-11. La règle n'était verrouillée que dans manager-app :
manager-app/lib/api/openApiTest.dart(dernier run daté du 2026-05-07)mymuseum-visitapp/lib/api/openApiTest.dart— le plus dangereux : il génère depuis son proprelib/api/swagger.yamlvers unmanager_api_newlocal, ce qui aurait créé un second client divergent dans un repo qui consomme celui de manager-app parpath:. Il portait en plus une des 5 erreurs Dart du repotablet-app/lib/api/openApi.dart— même configuration (nom de fichier différent, d'où le fait qu'il ait échappé aux recherches précédentes suropenApiTest)- plus un résidu
tablet-app/manager_api_new/ne contenant qu'unpubspec.lock, non suivi par git, daté d'avril — une génération avortée Les trois sont supprimés. Contrôle de non-régression du verrou :grep -rl "@Openapi" */libne doit renvoyer aucun fichier.
- Caractérisé précisément le 2026-08-11.
- Haute — Duplication du shell de dialog (15 occurrences), composants quasi-copiés (translation containers), méthodes
build()monolithiques (440-640 lignes), i18n contournée (~100+ strings en dur) - Moyenne — Clé AES en dur + IV fixe (chiffrement de session inutile), erreurs API avalées silencieusement (
catch (_) {}),_localPathcassé, fichiers morts, TODO visibles à l'écran en prod, dépendances pubspec inutilisées - Basse — façades API nullable jamais renullifiées, 58
print()de debug, credentials de test en dur, nommage non conforme Dart,test/widget_test.dartcassé (seul test du projet, ne compile pas)
Prochaine chose à faire si tu as 1h : la rotation des secrets Git → le nettoyage de manager_api_new (Critique manager-app). La rotation des secrets a été déplacée en fin de backlog le 2026-08-11 (repos privés auto-hébergés, cf. ci-dessus) ; le nettoyage du client, lui, est une demi-heure sans risque de build et il rend flutter analyze utilisable pour tout le lot design.
4. Infrastructure & Ops
- ✅ Travail non committé — résorbé. Revérifié le 2026-08-11 (2e passe) : plus rien en attente. Les 9 repos (DOCS compris) sont propres (
git status --porcelainvide partout) et à jour avec leur remote (aucunahead/behind). L'alerte du 2026-08-09 et sa révision du matin du 11/08 (« ~41 fichiers en working tree dont 9 non suivis ») sont toutes deux périmées : le lot 3 RAG est dans18e4240, le socle visuel + Guide IA danseb8e849. L'étape 1 du lot A est terminée — ne pas la re-planifier- Derniers commits par repo :
manager-service18e4240(RAG : pipeline d'ingestion, endpoints du guide IA, journalisation RGPD) ·manager-appeb8e849(socle visuel et écran Guide IA à deux onglets) ·visitapp-web473fc3a·mymuseum-visitappaf44927·tablet-app5a6701d(drapeaux du migrateur Flutter) ·myinfomate-landingd91cf7f·unov-landingcfa70c4·OpenGlasses1d887d7 - Rappel de l'état des branches :
manager-service→MyInfoMate_3.0,manager-app→Interface-refacto,mymuseum-visitapp→Meta-Rayban-Test(23 commits d'avance surmaster, voir §5bis),tablet-app→AI-Assistant-test,visitapp-webet les deux landings →master
- Derniers commits par repo :
- pg_dump quotidien pour
manager-service(Postgresmy_info_mate), en complément du snapshot OVH — voir mémoireproject_pgdump_todo, pas encore fait au 2026-07-15. ⚠️ Devient bloquant au moment de la bascule : c'est le point 18 du §1quinquies, le seul filet du jour J- ⛔ Deuxième chose qu'il débloque : le job
visit-events-purge(purge des stats au-delà de 13 mois) est codé et enregistré mais volontairement inactif — il ne supprimera rien tant queStats:RetentionDaysn'est pas défini. Ne l'activer (Stats__RetentionDays=395) qu'une fois ce pg_dump en place
- ⛔ Deuxième chose qu'il débloque : le job
- Fermer le port PostgreSQL exposé publiquement via Traefik/Docker (détail dans todo-features.md, section "Infrastructure — Sécurité réseau") — accès DBeaver via tunnel SSH uniquement
- Backup Mongo automatique en cron — voir todo-landing-deploy.md, section 5 (dump +
docker cpvers l'hôte, pas seulement dans le conteneur) - Déploiement landing page (
docker-compose-landing.ymlséparé, ne jamais toucher au compose principal) — voir todo-landing-deploy.md pour les règles à respecter
5. Roadmap produit — nouveaux canaux & IA
Plans détaillés, pas encore démarrés en dev (sauf mention contraire) :
| Chantier | Détail | Statut |
|---|---|---|
| Kiosk web (Flutter Web, extension tablet-app) | architecture-web-saas.md | 📦 V2 (2026-08-07) — pas commencé, ~2-3j estimés. Aucun client ne l'attend, et tablet-app ne compile même pas aujourd'hui |
Visiteur web (Next.js, app.myinfomate.be/[slug]) |
architecture-web-saas.md | ⚠️ V1, presque fini — visitapp-web existe, build ✅, les 13 types de section sont rendus, assistant IA inclus. Il ne manque que le Dockerfile + l'entrée app.myinfomate.be dans le compose. Bloquant : le plan Essentiel vendu par l'onboarding est web-only |
| Export PDF stats — à la demande | plan-import-ia-stats-subsides.md §1 | ✅ Livré le 2026-08-07, généré côté client (paquet pdf Dart), aucun changement backend. Inclus dans tous les plans qui ont les stats (décidé le 09/08) — la différenciation vient du contenu, pas d'un verrou |
| Historique des stats — 13 mois pour tous | idem · todo-features.md § Plans & Quotas | ✅ Décidé et appliqué le 2026-08-09. L'axe « durée » disparaît, HasAdvancedStats devient le seul différenciateur stats. Migration UnifyStatsRetentionTo13Months à appliquer. Purge codée mais inactive jusqu'au pg_dump |
| Export PDF stats — envoi mensuel automatique | idem | v2. C'est là que QuestPDF + Hangfire reprennent leur sens. Manquent : config destinataires/fréquence, méthode d'envoi avec pièce jointe, et libfontconfig1 au Dockerfile |
| Import IA de contenu + RAG (pgvector) | plan-import-ia-stats-subsides.md, v2/rag-pgvector-integration-plan.md | Priorité 2 — chantier fusionné, ~4-6 semaines. Le schéma part avec la migration v3, voir §1ter |
Médias & stockage (compression, IStorageService, quota) |
v2/media-storage-plan.md | Prérequis à la release de migration — voir §1ter |
| Visite hors ligne — remise en état | v2/offline-visit-plan.md | Bugs, pas de la roadmap — voir §1ter |
| Écran Guide IA (manager-app) | v2/guide-ia-screen-plan.md | V1, spécifié et maquetté — voir §1ter |
| Écran Statistiques — refonte | v2/stats-screen-plan.md | ✅ Livré le 2026-08-07, export PDF à la demande compris — les 7 défauts corrigés. Pas encore ouvert dans un navigateur : test-plan.md §8bis et §8ter |
| TTS pré-généré (audioguide multilingue) | v2/tts-pregenerated-plan.md | Priorité 3 |
| Talking head (avatar animé) | v2/talking-head-plan.md | Priorité 6 — polish, bouche-trou |
| AI Persona par venue (guide IA personnalisable) | v2/myinfomate-ai-persona-analysis.md | Architecture de référence pour les chantiers IA ci-dessus |
| Lunettes Ray-Ban Meta | roadmap.md (section XR), rayban-meta-integration.md | 🔨 POC fonctionnel — bien plus avancé que ce que cette ligne disait jusqu'au 2026-08-09. Voir §5bis |
| VR Meta Quest (borne office tourisme) | roadmap.md (section XR) | Idée, zéro ligne de code dans tous les repos — vérifié le 2026-08-09. La landing annonce pourtant « Meta Quest — Bientôt » en 4 langues (translations.ts, clé mode4Desc) |
| Canal subsides | plan-import-ia-stats-subsides.md | Actions administratives/réseau, pas de dev |
5bis. Lunettes Ray-Ban Meta — état réel du code (relevé le 2026-08-09)
Cette section corrige une erreur de ce fichier : la roadmap classait l'AR lunettes en « pas commencé, pilote subventionné quand le SDK sort de preview ». Un POC fonctionnel existe depuis mai-juin 2026, ~2 700 lignes de Dart dans
mymuseum-visitapp, sur la brancheMeta-Rayban-Test(23 commits d'avance surmaster, jamais mergée — c'est la branche de travail courante du repo).
| Brique | Fichier | État |
|---|---|---|
| SDK Meta | pubspec.yaml:84 — meta_wearables_dat: ^0.1.3 |
Dépendance active, pas commentée |
| Connexion / photo / stream vidéo | lib/Services/meta_glasses_service.dart (241 l.) |
Cycle de vie complet, routage audio HFP via canal natif |
| Pipeline mains-libres | lib/Services/Glasses/voice_orchestrator.dart (408 l.) |
wake word → STT → dispatch → LLM ou scan QR → TTS, sons d'état |
| Moteurs interchangeables | lib/Services/Glasses/engines/ + impl/ (~1 040 l.) |
4 interfaces, 11 implémentations : openWakeWord, Porcupine, speech_to_text, Whisper, Home Assistant STT, Gemini TTS, ElevenLabs, flutter_tts, client LLM MyInfoMate |
| Service de fond | lib/Services/Glasses/glasses_background_service.dart |
Téléphone en poche, écran verrouillé |
| UI | VoiceModeSheet, GlassesStatusWidget, GlassesDebugPanel (450 l., via AdminPopup) |
Monté dans main.dart:109-112 |
Commit c652604 : « Working flow also in background ! With custom wakeword working (hey visit et hey viva). flow llm working + take photo + scan qr code working ».
Ce qui bloque réellement la commercialisation
Pas « le SDK est en preview » — la liste est plus concrète :
- Android uniquement (
if (!Platform.isAndroid) return;dansmeta_glasses_service.dart). Le modèle BYOD tombe pour tous les visiteurs iPhone. Seul le modèle « kit prêté par le lieu » tient aujourd'hui. - Pas de distribution store tant que le SDK est en developer preview : chaque installation passe par les credentials développeur.
- ✅
TTS = ElevenLabs, facturé au caractère — basculer sur Gemini avant de vendre l'add-onDéjà fait, constaté le 2026-08-13.constants.dart:20dit « ElevenLabs retiré du pipeline (trop cher) » etVoiceControllerne construit queGeminiTtsEngineou la voix système. Le risque de marge qui motivait cette ligne est écarté.impl/elevenlabs_tts_engine.dartest conservé comme option — à rouvrir seulement si des clients jugent la voix Gemini insuffisante, en sachant que c'est beaucoup plus cher. - ⚠️ Mais deux défauts ont vécu derrière cette ligne, corrigés le 2026-08-13.
- Le choix de voix du client n'était pas honoré. Le sélecteur Viva (
Sulafat) / Marco (Umbriel) existe dans manager-app depuis le 07/08 et écritInstance.GuideVoiceId;mymuseum-visitappne lisait jamais ce champ et utilisait une constante de build, dont le défaut étaitAlgieba— une voix que personne n'a écoutée. Même forme que W1 : un réglage offert par le CMS que l'app visiteur ignore. La voix vient désormais de l'instance, la constante n'étant plus qu'un repli (passé àSulafat). - Aucun APK ne parlait avec la voix du produit.
GeminiTtsEnginen'est choisi que siGEMINI_API_KEYest injectée au build — elle ne l'était nulle part, donc tous les builds retombaient surFlutterTtsEngine, la voix système d'Android, en silence. Le repli s'annonce maintenant dans les logs,constants.dartporte la commande, etlaunch.jsona une configuration « Dev + voix Gemini ». ⚠️ Le repli reste le défaut, et c'est voulu : les tests de terrain ne consomment ni jetons ni argent.
- Le choix de voix du client n'était pas honoré. Le sélecteur Viva (
- ⚠️ Le STT n'est pas Google non plus :
WhisperSttEnginepointe surapi.openai.comsiWHISPER_API_KEYest définie, sinon repli surspeech_to_text, le moteur du device. La clé est vide aujourd'hui, donc c'est le device qui transcrit. À trancher au lot J : activer Whisper ajouterait OpenAI comme sous-traitant, que le §8.5 des CGU ne mentionne pas — il ne parle que de Google. - Wake word de prod non réglé :
porcupine_flutterest commenté danspubspec.yaml(payant), l'implémentation courante s'appuie surspeech_to_text/ openWakeWord. - Branche jamais mergée dans
master. - Le POC ne compile plus en l'état — relevé le 2026-08-11.
flutter analyze libsurmymuseum-visitappremonte 4 erreurs, toutes le même manque :kElevenLabsApiKeyetkElevenLabsVoiceIdsont absents deconstants.dartalors queglasses_qr_scanner_service.dart:110-111etwake_word_service.dart:133-134les utilisent et importent bienconstants.dart. La doc deglasses_tts_service.dart:31-32confirme l'intention (« typiquementkElevenLabsApiKeydeconstants.dart»). Ces deux constantes sont des secrets qui n'ont jamais été committés — le POC tournait avec unconstants.dartlocal. Conséquence pratique : « démontrable » suppose de les remettre, ce n'est pas ungit checkoutqui suffit. À traiter avec la bascule TTS → Gemini ci-dessus, qui les rend inutiles. - ✅
— levé le 2026-08-12 par K5. Les trois flavors construisent ; le NDK est passé à 28.2.13676358 et Kotlin à 2.3.10.flutter build apkne passe plus non plus - ⛔ Correction du 2026-08-13 : « l'APK se construit sans le POC dedans » est faux, et la conclusion qu'on en tirait aussi. Les 4 erreurs ne sont pas dans le POC vivant, elles sont dans ses ancêtres :
wake_word_service.dartn'est importé par personne, etglasses_qr_scanner_service.dartseulement par lui — un îlot de deux fichiers hors du graphe demain.dart, la génération d'avant l'orchestrateur. Le POC actuel, lui, est bien dans l'APK :Services/Glasses/est importé parVoiceController, etGlassesStatusWidgetest monté sur l'accueil.
⚠️ Conséquence pratique, inverse de celle qui était écrite ici : « démontrable » ne suppose pas de remettre les deux constantes ElevenLabs. Le chemin vivant choisitGeminiTtsEnginedès quekGeminiApiKeyest renseignée (voice_controller.dart:94-100) et ne touche jamaiskElevenLabs*. La bascule TTS → Gemini réclamée ci-dessus est déjà faite dans le code ; ce qui reste est de supprimer les deux ancêtres morts. - ✅
Branche jamais mergée / à trancher avant K6— tranché le 2026-08-13 par le propriétaire du projet :Meta-Rayban-Testest la branche de travail à jour, pas un POC de côté. Le nom est trompeur, il date de la première expérimentation ; tout le travail V1 demymuseum-visitappy vit et les lunettes n'en sont qu'une partie — invisibles, d'ailleurs, si l'instance n'a pas l'assistant. On développe et on publie depuis elle. Idemtablet-appsurAI-Assistant-test. Rien à clarifier avant K6 ; c'est ce paragraphe qui affirmait le contraire et fabriquait l'alerte.
Latence, langues et accusés de réception — relevé le 2026-08-13
Plan complet : voice-latency-plan.md, découpé en « maintenant / après les tests / V2 ». Ce qui suit n'en garde que les constats vérifiés dans le code.
- ⛔ L'assistant vocal ne parle réellement que FR/NL/EN/DE, et personne ne le disait.
_toLangCode(voice_orchestrator.dart:399-407) ne mappe que ces quatre langues et renvoiefr-FRpar défaut ;constants.dart:59-70en déclare 10. Un visiteur en italien se fait répondre en français, sans erreur ni trace. Décidé le 2026-08-13 : la limite est assumée et documentée, le reste de l'app garde ses 10 langues. Le coût d'en rajouter une est faible et c'est vérifié — les traductionsvoice.*existent déjà pour les 10 langues danstranslations.dart, Whisper prend le code générique et est multilingue, le wake word est phonétique. - ⛔ Et les quatre langues annoncées ne le sont pas non plus.
_isStopCommand,_isRepeatCommand,_isQrScanCommand,_isPhotoCommand(:375-397) cherchent des mots français en dur — « répète », « arrête », « prends », « regarde ». Elles avaient été écrites en français pour tester et n'ont jamais été reprises. Un néerlandophone qui dit « herhaal » n'est pas compris, et_isQrScanCommandmatche surcode: « what's the code of this painting » déclenche un scan QR. À corriger avant le lot H — sinon les tests multilingues valident autre chose que ce qu'on croit. - ⚠️
done.mp3retarde la réponse de toute sa durée.:212faitawait _playDoneSound()juste avantttsEngine.speak(), et_playSoundattendplay(), dont le future ne se résout qu'à la fin de la lecture. En prime c'est un doublon : la parole est le signal de fin. - ⚠️ Le time-to-first-audio est la somme de tout.
LlmClient.chat()retourne un future de réponse complète (pas de flux) etGeminiTtsEngine._synthesize()fait ungenerateContentunaire qui attend tout le PCM avant d'écrire le WAV. D'où le son de réflexion en boucle, qui bouche ce trou. Le remède qui ne dépend d'aucune API nouvelle : découper la réponse en phrases et synthétiser la première pendant que les suivantes se préparent — contenu dansGeminiTtsEngine, sans toucher au backend. - 💡 Idée retenue mais repoussée : remplacer le bip du wake word par une phrase parlée (« Oui, je vous écoute ») dans la langue et la voix du visiteur, 2-3 variantes. Repoussée après les tests parce qu'elle demande de générer et valider à l'oreille ~40 fichiers (2 voix × 4 langues × 5 phrases), et qu'une partie du besoin qu'elle compense disparaît si la latence baisse. ⚠️ Règle de cohérence si on la fait : les acks ne s'activent que si le moteur runtime est Gemini et que la voix correspond à
guideVoiceId— des acks en Sulafat suivis d'une réponse en voix système Android seraient pires que le bip. - 🔭 Piste V2 : le Live API de Gemini (WebSocket, audio natif bidirectionnel) supprimerait les trois maillons Whisper → LLM → TTS et débloquerait le barge-in et le VAD serveur. Faisable, mais le tool calling devrait passer par un proxy WebSocket dans
manager-service(option retenue sur le papier), le modèle de coût passe à la session ouverte avec des jetons audio bien plus chers, et les modèles sont en preview. À ouvrir par un spike chiffré d'une journée, pas par une décision.
Ce que ça change côté commercial
Le POC est démontrable. Face à un concurrent mono-usage type Musa Guide, une démo qui tourne pèse plus qu'une ligne « bientôt » sur la landing. À condition d'assumer le kit Android prêté, et de ne pas vendre l'add-on avant la bascule TTS.
6. Sous-projets MyInfoMate
- MyInfoMate Sport — pilote Hockey Namur. Vue d'ensemble : sport/solution-overview.md, spec : sport/myinfomate-sport-spec.md, pitch : sport/pitch-commercial.md
- MyInfoMate Crèche — pilote childcare en cours. Vue d'ensemble : creche/solution-overview.md, spec : creche/myinfomate-creche-spec.md, pitch : creche/pitch-commercial.md
6bis. Cohérence de la doc — audit du 2026-08-07
Passage en revue de tous les fichiers DOCS/ (hors sport/ et creche/). Corrigé directement :
| Fichier | Ce qui était faux |
|---|---|
| roadmap.md | Annonçait Claude API comme LLM à 4 endroits — c'est Gemini 2.5 Flash-Lite. Quota IA en requêtes → tokens |
| parity-manager-visitapp.md | L'intro du §6 disait « visitapp-web reste cassé » et B2 « tests backend ❌ » alors que les deux sont corrigés depuis le 2026-08-06 — le doc se contredisait lui-même |
| v2/tts-pregenerated-plan.md | Chemin Firebase « à confirmer » et faux. Le vrai est pictures/{instanceId}/{resourceId}, sans extension. Le refactor uploads est partiellement devancé par media-storage-plan.md |
| plan-import-ia-stats-subsides.md | « Aucune infra email n'existe » → Resend est en place (9 templates, domaine vérifié). Quota AiRequestsPerMonth → AiTokensPerMonth |
| security/audit-securite-manager-service.md | ImageHelper : confirmé code mort qui planterait sur Linux. Double SaveChangesAsync : à revérifier après le fix BuildAuditEntries du 2026-08-06 |
| solution-overview.md | Grille tarifaire Starter/Standard/Premium (69/99/199 €) — structure abandonnée. Marquée obsolète |
🔴 Demande ton arbitrage — non corrigé volontairement
cgu-myinfomate.md §5 décrit des plans qui n'existent plus : Starter / Standard / Premium (69 / 99 / 199 € HTVA), quotas IA en requêtes. La réalité est Essentiel / Pro / Premium / Enterprise (39 / 99 / 179 €), quotas en tokens.
C'est un document contractuel — je ne l'ai pas réécrit. À reprendre en vérifiant d'abord ce que les clients existants ont effectivement signé.
Ménage — décidé le 2026-08-07
offre-commerciale.md: ne pas supprimer. Vérification faite, il ne contient pas qu'une grille : clients cibles, services complémentaires, forfait événementiel, frais de mise en place, projection de revenus. Retirer les seules tables de prix et garder le reste comme document de stratégie commerciale.- Quatre documents portaient une grille tarifaire (roadmap, solution-overview, offre-commerciale, cgu) alors que la source de vérité est
myinfomate-landing→ supprimer les tables, garder un lien. - ✅ CGU §5 reprises le 2026-08-07 depuis la landing : Essentiel/Pro/Premium/Enterprise (39/99/179/devis), et un §5.1bis qui définit le quota IA en tokens en renvoyant au back-office pour les valeurs — plutôt que de figer un chiffre dans un contrat.
- ✅ Landing corrigée — la clé
features.reqPerMonthn'existe plus dansmyinfomate-landing/src(vérifié le 2026-08-11). Ce point était encore listé comme ouvert à deux endroits de ce fichier alors que le kanban le donnait fait : c'est le kanban qui avait raison. - ⚠️ Champs morts — partiellement traités.
FactContent,IsStepLocked,IsHiddenInitiallysont bien supprimés (migrationRemoveDeadGuidedStepFlags). Restent en base :SectionEvent.ParcoursIds,SectionEvent.IconResourceId,SectionMap.MapResourceId— ce dernier étant en plus mappé versiconResourceIddans son DTO, ce qui ajoute une confusion de nommage. - Champs « morts » — décision révisée le 2026-08-07 après vérification du code. Deux des trois ne sont pas morts :
SectionMap.MapResourceId→ à renommer, pas à supprimer. Il est branché de bout en bout (SectionFactory:179/511,SectionMap.ToDTO:58,MigrationController:469) et exposé au front sous le nomiconResourceId: la colonne porte l'icône de la carte, pas une « ressource carte ». Le renommer enIconResourceIdsupprime la confusion. Reste à décider si le web doit l'honorer (écart W10).SectionEvent.ParcoursIds→ à investiguer avant de trancher. Intention produit documentée (depuis un SectionEvent mis en avant, retrouver les parcours liés aux jours d'un carnaval).GuidedPath.SectionEventIdcouvre le lien au niveau événement ; reste à vérifier qu'il couvre aussi le niveau jour / bloc de programme. Ne pas supprimer tant que ce n'est pas établi.SectionEvent.IconResourceId→ sans usage visiteur identifié, seul candidat réel à la suppression.
Section.meterZoneGPS: à implémenter, pas à retirer. Le rayon de déclenchement a une vraie raison d'être variable — une salle de musée demande ~10 m, un parcours extérieur comme le Fort en demande 50-100. La constante en dur à 100 m est fausse dans les deux cas.
7. Documents de référence (pas des todos — pas besoin de "traiter")
- solution-overview.md — présentation produit core
- parity-manager-visitapp.md — audit de parité manager-app → apps visiteur (2026-08-05) : couverture par section, 17 écarts au niveau champ, état des builds, méthode pour refaire l'audit
- guided-path-types.md — guide de référence SectionParcours
- section-event-map-setup.md — guide setup SectionEvent/SectionMap
- design-prompts.md — prompts design pour Google Stitch/Claude Design
- competitor-analysis.md — comparatif concurrents
- cgu-myinfomate.md — CGU
- ⚠️ offre-commerciale.md — obsolète, source de vérité = myinfomate-landing (voir mémoire
project_pricing_plans) - ⚠️ roadmap.md — contient aussi une table "Modules existants" + une table de prix en dehors de la section XR (section 5 ci-dessus) : cette table de prix est une ancienne copie, potentiellement désynchronisée de myinfomate-landing (source de vérité) — à vérifier avant de s'y fier
openwakeword/— scripts/notebook d'entraînement de modèle wake word (code, pas de la doc à jour manuellement)claude design/— mockups HTML générés (MyInfoMate Parcours), à consulter visuellement, pas du texte à synchroniser
Comment mettre à jour ce fichier
🗂️ Le kanban en est le reflet. Source de vérité du contenu : kanban.html, versionné à côté de ce fichier. Publié à l'adresse https://claude.ai/code/artifact/4c317904-2e23-45d3-ac14-dcca678ae5d3
⚠️ L'artifact appartient à un autre compte Claude que celui de la machine de dev. Constaté le 2026-08-10 : ni la lecture ni la republication n'y sont possibles depuis ici — même partagé en « anyone with the link », la réponse est « reading public artifacts that way is not enabled yet ». D'où le découpage :
Qui Quoi Contenu n'importe quelle session éditer DOCS/kanban.html— cartes et compteurs (colonnes + bandeau du haut)Diffusion le compte propriétaire republier ce fichier sur l'artifact Vérification avant publication : chaque compteur annoncé doit égaler le nombre réel de
<article class="card">de sa colonne.Un hook
PostToolUse(.claude/hooks/status-kanban-reminder.py) rappelle ce report à chaque écriture sur ce fichier. Il rappelle, il ne publie pas — une correction de formulation ne justifie pas de toucher au tableau.
Ce fichier est un index, pas une source de vérité duplicée :
- Nouvelle feature terminée/à faire → éditer todo-features.md, pas ici (sauf si ça change le résumé de la section 1)
- Nouveau point sécurité traité → cocher dans la section 3 ici et noter "Déjà corrigé" dans le fichier audit correspondant
- Nouveau chantier roadmap → ajouter une ligne dans la table de la section 5, créer le plan détaillé séparément si ça dépasse quelques lignes