Port PostgreSQL exposé publiquement
À fermer via Traefik/Docker. Accès DBeaver par tunnel SSH uniquement.
todo-features.md — Sécurité réseauVue d'état consolidée depuis DOCS/. Source de vérité : STATUS.md — cette page en est le reflet, pas le remplaçant. Filtrez sur V1 pour ne voir que ce qui reste avant la mise en prod. L'ordre d'exécution, lui, est dans v1-plan.md : ce tableau dit où en est chaque chantier, pas lequel bloque lequel.
🎉 Le développement V1 est clos depuis le 13/08. Les lots F et J sont terminés. Un bug rouvert le 25/08 : la jauge de stockage du menu latéral ne se rafraîchit pas après un ajout de média — affichage seul, le comptage serveur est juste. Ce qui suit est du test (dont le §21 sur device, qui n'a jamais été joué), du juridique (relecture du §8 des CGU, conditions Google, DPA) et de l'infra (secrets, pg_dump, bascule) — plus une ligne de code applicatif à écrire, hors K9 qui est du confort visuel sacrifiable. Une exception assumée le 25/08 : le dock de filtres en paysage de visitapp-web, écrit parce que l'assistant recouvrait un bouton de la carte — livré, mais jamais vu tourner, la base étant vide.
Ajouté le 28/08, côté commercial : un chantier de grille tarifaire sur la landing — les frais de mise en place y sont annoncés sans chiffre, ce que l'acheteur découvre en rendez-vous — et le volet commercial du TTS pré-généré (facturer la génération et non le stockage, self-service en prérequis d'annonce, argument accessibilité). Aucune ligne de code applicatif : de la copie de site et une règle de facturation.
Et un chantier matériel, ouvert le même jour : les iBeacons n'ont jamais été testés sur un device alors qu'ils sont vendus dans Pro et Premium — avec un piège iOS lisible dans le code (UUID en dur, filtrage absent sur Android). Le test suppose des balises en main : d'où un sourcing à lancer (Holyiot, puis Feasycom FSC-BP104D), dont une conclusion technique ferme — la télémétrie de batterie doit passer par le major, le minor servant déjà à identifier le point d'intérêt.
À fermer via Traefik/Docker. Accès DBeaver par tunnel SSH uniquement.
todo-features.md — Sécurité réseauConstaté le 08/09 sur la préprod, depuis internet, sans authentification :
curl "https://api.mymuseum.be/api/Configuration?instanceId=633ee379d9405f32f166f047"
→ 200, avec le contenu complet et ses traductions
La même requête avec X-Api-Key renvoie le même 200. Cause exacte, vérifiée le 08/09 : Get (ConfigurationController.cs:52) et GetDetailAsync (:117) portent un [AllowAnonymous] explicite. Ce n'est donc pas une clé mal vérifiée, c'est une ouverture assumée — probablement héritée de la v2, où les apps visiteur n'avaient pas de clé. Les apps l'envoient pourtant (client.dart:54), et le mécanisme existe et fonctionne : ApiKeyAuthenticationHandler + la policy AppReadAccess, déjà utilisée par byPin (:83) et export (:383) du même contrôleur.
Ce que ça expose : le contenu éditorial de n'importe quelle instance, à qui connaît un instanceId — lequel se lit en clair dans un APK de flavor, ou se devine à partir d'un export. Pas de données personnelles, mais tout le travail éditorial d'un client.
✅ Audit complet fait le 12/09 — 40 routes anonymes recensées, dont 2 seulement contrôlent la clé (Configuration/{id}/export et Instance/{id}, tous deux corrigés en septembre). Le trou est donc général, pas ponctuel : 24 routes de contenu sont ouvertes — Configuration (2), Section (4), Resource (2), SectionMap (3), SectionEvent (3), SectionAgenda (2), SectionParcours (2), SectionQuiz (1), ApplicationInstance (2).
🔴 Trouvaille plus grave que la carte, corrigée le 12/09. GET /api/Instance/slug/{slug} était anonyme et sans filtrage : il rendait le pinCode — celui qui ouvre l'appairage des tablettes et des casques — plus l'adresse de facturation, le numéro de TVA, les quotas et le plan du client. Et le slug n'est pas un secret : c'est l'URL du site visiteur (app.myinfomate.be/{slug}). La fonction de filtrage StripCommercialFields existait déjà, avec un commentaire qui nommait le risque du pinCode — elle n'était simplement pas appelée ici. Corrigé sur slug et sur byPin, avec 3 tests qui verrouillent le contrat (243 tests au vert). Deux champs restent exposés à dessein : publicApiKey, sans laquelle visitapp-web ne peut pas s'amorcer, et isTrialActive, qui sert au filigrane d'essai affiché au visiteur.
⚠️ Ce que fermer les 24 routes apportera vraiment — et ce que ça n'apportera pas. La clé s'obtient anonymement : par le slug (qui est dans l'URL) ou par app-key avec le pincode. Après fermeture, le contenu ne sera donc pas confidentiel : il sera lisible par qui lit une URL, au lieu de qui devine un instanceId. Le gain réel est ailleurs, et il compte : plus d'énumération par identifiant, un accès tracé et révocable, et un seul chemin d'entrée au lieu de vingt-quatre. Rendre le contenu réellement privé serait un autre chantier, avec une autre décision produit.
Reste à faire : fermer les 24 routes de contenu. Deux obstacles connus. Un : [Authorize] de classe et d'action se combinent — c'est pour ça qu'export passe par [AllowAnonymous] + contrôle manuel ; il faut donc un attribut réutilisable, pas un simple changement de policy. Deux : deux routes doivent rester anonymes et c'est écrit dans le code de visitapp-web — instance/slug/{slug} (l'amorce, qui distribue la clé) et ApplicationInstance?instanceId= (la page /download, où le visiteur arrive d'un QR imprimé sans slug ni clé).
Relevé le 14/09 sur le projet mymuseum-3b97f (release firebase.storage/mymuseum-3b97f.appspot.com, ruleset 025f98db-…, inchangé depuis le 08/01/2024) :
match /{allPaths=**} {
allow read, write; //: if request.time < timestamp.date(2024, 1, 12)
}
La garde temporelle du template Firebase a été commentée pour qu'elle n'expire pas. N'importe qui connaissant le nom du bucket — il est dans l'URL de chaque image, donc public — peut lire, lister, écrire et supprimer le contenu de tous les clients. Vérifié sans aucune authentification : GET firebasestorage.googleapis.com/v0/b/mymuseum-3b97f.appspot.com/o répond 200 avec la liste des fichiers.
Pourquoi ce n'est pas déjà fermé : le manager-app déployé écrit dans le bucket sans Firebase Auth. Fermer avant de déployer casse ses uploads. La fermeture est donc séquencée après la mise en production du lot 0 du Studio (upload par URL signée + ingestion serveur), comme le prévoit v2/studio-plan.md §8. ⚠️ La date de ce déploiement est donc une date de sécurité, pas seulement une date de feature — ne pas mettre la version actuelle en production sans traiter ce point dans la foulée.
Piège à traiter dans le même geste : le CORS du bucket de prod n'a aucun responseHeader. Le jour du déploiement, le PUT signé portant Content-Type et x-goog-content-length-range sera refusé au préflight et tous les uploads casseront. À corriger sur le bucket avant la bascule. Le bucket de dev mymuseum-3b97f-dev, lui, est déjà configuré correctement (CORS, cycle de vie incoming/, règles fermées) et sert de modèle.
Une piste applicable avant le déploiement, à vérifier : restreindre read/list seul. Les apps visiteur lisent par URL à jeton, qui ne passe pas par les règles ; reste à confirmer qu'aucun code client ne lit par le SDK Firebase.
show_resource_popup.dart:130-137 supprime l'objet du bucket après avoir supprimé la ligne, et avale l'échec dans un print — un échec laissait un orphelin définitif que le quota ne comptait plus. Depuis C3 (12/08) le serveur supprime le blob avant la ligne : le client tombe désormais sur un objet déjà absent. Ce n'est plus dangereux, c'est devenu inutile. ⚠️ Ne pas le retirer avant d'avoir renseigné Firebase:StorageBucket en prod (carte « Bascule prod ») : la clé est vide par défaut, et sans elle le serveur ne supprime rien — retirer le code client ferait perdre la fonction au lieu de la déplacer.
QuotaBarsWidget appelle instanceGetQuota une seule fois, dans initState (quota_bars_widget.dart:23), et il est monté dans le pied du menu latéral persistant (main_screen.dart:477) — jamais reconstruit en naviguant. Le chiffre affiché date de l'ouverture de la session ; ajouter ou supprimer un média ne le fait pas bouger avant un rechargement complet de la page.
⚠️ Le comptage lui-même est correct — ne pas partir chercher le bug côté serveur. storageUsedBytes est recalculé à chaque appel (SUM(SizeBytes), InstanceController.cs:383), et SizeBytes est renseigné à la création sur les deux chemins (multipart :277, JSON :341) puis confirmé à l'Update avec la taille réelle après compression. Le contrôle d'upload, lui, refait un GET /quota juste avant de téléverser (resources_screen.dart:186-198) : d'où l'incohérence visible pour le client — une jauge à « 0 KB » et un refus 413 sur le même chiffre, lu à deux moments différents.
Correctif : exposer un rafraîchissement de la jauge et le déclencher en fin de create() et de suppression de ressource. Pas de plomberie d'état à introduire pour ça.
Le visiteur ne sait jamais combien il télécharge. Le dialogue de configurations_list.dart annonce « il est nécessaire de télécharger du contenu supplémentaire » sans chiffre, et l'écran de progression n'affiche qu'un pourcentage. Or une visite du Fourneau, c'est un ordre de grandeur de plusieurs centaines de Mo sur la 4G du visiteur.
Google Play exige que la taille des téléchargements complémentaires soit annoncée avant de les lancer — c'est une condition de publication, pas un confort.
Rien à ajouter côté serveur : ResourceDTO.sizeBytes existe déjà et arrive dans ExportConfigurationDTO.resources. La somme se calcule côté client, et sur les seules ressources réellement à télécharger — isResourceOutdated filtre déjà celles qui sont à jour, donc une mise à jour de visite doit annoncer le delta, pas le total.
Deux endroits : le dialogue de confirmation (avant), et l'écran de progression (« 128 / 412 Mo » plutôt qu'un pourcentage nu). ⚠️ La taille annoncée dans le dialogue suppose d'avoir déjà appelé configurationExport pour connaître la liste — aujourd'hui cet appel n'a lieu qu'après la confirmation.
Deux calques se disputaient le coin bas-droit. Le lanceur de l'assistant est monté pour toutes les sections ([configId]/layout.tsx, right:20/bottom:20/z-1200) et recouvrait la bascule Carte/Liste de MapSection (z-1000) : c'est elle qui disparaissait. En parallèle le panneau de filtres, posé left-3 right-3, masquait en paysage la carte qu'on filtrait.
Le parti vient de tablet-app (Screens/Map/geo_point_filter.dart) : en paysage, colonne vitrée à gauche — recherche, catégories, liste des points — la bascule Carte/Liste disparaît puisque le dock est la liste, et la fiche du point passe en colonne droite. Portrait inchangé, feuille basse. Même traitement pour la progression cartographiée de ParcoursSection (étapes à gauche, étape en cours à droite).
⚠️ Rien n'a été vu tourner : next build passe, mais /api/ApplicationInstance répond [] — la base est vide, il n'existe aucune instance à ouvrir. À reprendre dès qu'un jeu de données existe, et à éprouver dans les quatre combinaisons (téléphone/tablette × portrait/paysage), plus le cas < 420 px de haut où le dock doit démarrer replié.
Primitives partagées, à réutiliser plutôt qu'à recopier : ui/FloatingPanel.tsx (dock ↔ feuille basse selon l'orientation), ui/PointFilter.tsx (recherche sans accents, cases, groupes), et surtout --mim-assistant-inset-y/-x + .mim-clear-assistant dans globals.css — l'encombrement de l'assistant est déclaré une fois, sinon la collision revient à la section suivante.
Cinq réglages livrés le 13/08 dans voice_orchestrator.dart, tous à éprouver en visite réelle avec Viva et Marco, sur lunettes et téléphone.
1. done.mp3 retardait la réponse de toute sa durée — await _playDoneSound() juste avant speak(), et dans just_audio le future de play() ne se résout qu'à la fin de la lecture. En prime un doublon : la parole est le signal de fin. Retiré, helper et constante supprimés.
2. Sons préchargés — setAsset décodait à chaque wake word, sur le chemin le plus sensible à la latence du pipeline. 3. Escalade du son de réflexion — silence 700 ms, puis fondu d'entrée à 45 % ; une réponse rapide ne déclenche plus aucun son. ⚠️ stop() n'appelait pas _stopThinkingLoop() : le timer pouvait jouer une nappe après l'arrêt de l'orchestrateur.
4. Les commandes n'existaient qu'en français — _isStopCommand et les trois autres cherchaient « répète », « arrête », « prends » en dur. Les quatre listes couvrent FR/NL/EN/DE, et le matching passe de contains à mot entier. ⛔ Ce changement a révélé deux faux positifs déjà présents : « je ne comprends pas » déclenchait une photo, « raconte encore une histoire » rejouait la réponse précédente. Et code nu ne déclenche plus de scan QR (« what's the code of this painting »). 16 cas joués via dart run, 16/16.
5. Limite 4 langues actée dans le code. flutter analyze lib/Services/Glasses/ → 0 erreur.
Écrit et analysé, jamais ouvert dans un navigateur. À vérifier sur données réelles : la règle mono-canal, le rendu des titres longs dans les barres. Sur le PDF : les accents, et le logo — s'il manque, c'est le CORS du bucket Firebase, le rapport doit se générer quand même. Et après database update : que les instances soient bien passées à 395 jours (pas seulement les plans), et que le job visit-events-purge déclenché à la main ne supprime rien.
15 cas : images d'articles, audios, extensions de fichiers, fraîcheur, dégradation des types non offline. Conditionne l'urgence de tous les bugs hors ligne.
✅ Débloqué par K5 (12/08, les trois flavors construisent). Valide désormais aussi D2, D3 et D4, livrés le 12/08 — audio/mpeg reconnu, purge des fichiers obsolètes réactivée, filtre de fraîcheur sur dateUpdate.
⚠️ Deux cas à ajouter au §21 avant de le jouer, ouverts par D2 : (1) montée v3 → v4 de la base locale sur un device portant déjà une visite — elle ne doit rien re-télécharger ; (2) device portant des <id>.unknown — le MP3 doit être re-téléchargé et devenir lisible. Le second est le seul qui prouve que D3 répare le parc existant, pas seulement les installations neuves.
Les 5 cas du §9 sont écrits et aucun n'est coché : le déclenchement par balise n'a jamais été observé sur un téléphone. Vendu dans Pro et Premium (« Offline + beacons BLE » sur la landing), donc à prouver avant la première vente, pas après. Exige du matériel en main — d'où la carte de sourcing en « Planifié ».
⚠️ Le piège est iOS, et il est dans le code. geo_beacon_trigger_service.dart:148-155 ne pose une région filtrée que sur iOS, avec un proximityUUID en dur (FDA50693-A4E2-4FB1-AFCF-C6EB07647825) ; Android scanne Region(identifier: 'GlassesBeacon') sans filtre. Conséquence : une balise réglée sur un autre UUID marche sur Android et reste invisible sur iOS — et le test passerait sur le seul device qu'on a sous la main. Soit les balises commandées émettent cet UUID, soit il devient configurable. À trancher avec le sourcing, pas pendant le test.
⚠️ Deux chemins de scan à jouer séparément, ils ne partagent pas leur code : configuration_page.dart:197-205 (notification beaconFound) et geo_beacon_trigger_service.dart:166-189 (déclenchement proactif de l'assistant). ⚠️ Et configuration_page.dart:43 dit int meterToBeacon = 100; // 15 meters — le commentaire et la valeur se contredisent, et Section.meterZoneGPS, configurable par section dans manager-app, est ignoré. Un test à 100 m ne mesure rien : la distance de déclenchement est la seule chose que ce chapitre doit établir.
À mesurer sur place, pas au bureau : portée réelle derrière un mur, temps entre l'entrée en zone et la notification, et absence de spam quand on stationne devant un POI (le cooldown).
test-plan.md §9 · todo-features.md — Beacons / géofencingInscription, création d'instance, essai 14 j, Stripe, 9 emails Resend, job de cycle d'essai. Code écrit et compilé, aucun parcours joué. Reste l'alias onboarding@ et le whsec_ Stripe CLI.
Le scénario le plus complet : SectionMap + SectionParcours + géodéclenchement + questions + mode jeu, d'un seul tenant.
test-plan.md §19.13« Tout ce que je configure est-il visible ? » — le chapitre qui valide les 13 types sur les deux apps visiteur.
test-plan.md §195 commandes, bloquant absolu. flutter analyze et next dev ne prouvent rien — seuls les vrais builds disent la vérité.
v1-mediatheque-plan.md sont codées : Resource.Width/Height (migration AddResourceDimensions), fileName dans ToDTO(), ResourceUsageService + GET /usages et GET /usage-map, 409 sur la suppression d'une ressource utilisée et DELETE /bulk à rapport partiel ; côté front, renommage en Médiathèque, rail de facettes cumulables à compteurs (type · usage · configuration), tri, groupement par mois, bascule grille/liste, sélection multiple, compteur d'usages sur la vignette, et le panneau latéral qui remplace la popup de 520 px. Les deux bugs sont corrigés : un PNG se télécharge en .png, et FileName est exposé.
Deux corrections apportées à la spécification, vérifiées dans le code. Le « piège 1 » (union sur toutes les langues) était faux : GetReferencedResourceIds(null) rend déjà toutes les langues — appeler la méthode sans argument suffit. Et un quatrième piège manquait : un GuidedPath peut pendre d'un SectionEvent, dont GetReferencedResourceIds ne descend pas dans les parcours ; les parcours sont donc parcourus séparément, et volontairement pas chargés sur SectionParcours pour éviter le double comptage. Les deux sont figés par des tests (ImageUsedOnlyInDutch_IsNotOrphan, GuidedPathOnEvent_IsWalked). Piège 3 assumé et testé tel quel : GuidedStep.ImageUrl reste une URL, donc comptée orpheline — le tooltip de la facette « Jamais utilisées » l'écrit.
⚠️ Ce qui reste, et pourquoi cette carte est ici. dotnet test 236/236 et flutter build web passent — la checklist de 15 points du plan n'a été passée dans aucun navigateur. Le point le plus coûteux d'abord : ouvrir un champ image d'une section, choisir une ressource, enregistrer. showSelectResourceModal monte ResourcesScreen, donc une régression y touche les 13 types de section, les POI et les étapes d'un coup. Hors périmètre, laissé tel quel : la jauge de stockage du menu (quota_bars_widget.dart:23).
v1-mediatheque-plan.md — les 8 étapes sont codées
Trois trous de home_3.0.dart comblés le 2026-09-08, aucun vu tourner sur device.
(1) Les configurations désactivées s'affichaient. isActive ne vit pas sur ConfigurationDTO mais sur le lien config↔canal (AppConfigurationLinkDTO.isActive, ce que bascule app_configuration_link_screen:407). Ni le fetch par liens ni le set mobileConfigIds ne le lisaient. ⚠️ L'ancienne home avait le même bug — ce n'était pas une régression de la v3. La référence était à côté : visitapp-web/src/lib/api/client.ts:170 filtre déjà link.isActive && link.configuration.
(2) Une visite hors ligne était un cul-de-sac. Pas de bouton télécharger, pas de garde : la tuile ouvrait ConfigurationPage, et body.dart:250 lisant ses sections uniquement en base locale quand isOffline est vrai, le visiteur voyait un détail vide, sans message ni erreur. Porté depuis configurations_list.dart : badge en pastille de verre sur la tuile bento (↓ / ✓), tuile ternie tant qu'elle n'est pas prête, dialogue sombre choix de langue + progression (DownloadConfigurationWidget réutilisé tel quel). Zéro nouvelle clé i18n, tout existait dans les 10 langues.
⚠️ Piège trouvé en câblant : alreadyDownloaded se déduisait des lignes de la table configurations — table dans laquelle le fetch écrit une ligne par visite pour cacher order/gridSpan. Au deuxième lancement, toutes les visites se seraient déclarées téléchargées, et le clic aurait rouvert un détail vide. L'état se déduit maintenant des sections en base locale, ce que lit le détail.
(3) Passe visuelle. Nouveau Components/GlassPill.dart (verre dépoli, 3 usages) : le cog devient Icons.tune + drapeau de la langue courante en pastille, GlassesStatusWidget passe sur la même pastille, et la feuille de réglages passe en sombre — une feuille blanche dans une app noire était le contraste le plus violent de l'écran.
✅ À supprimer une fois validé sur device : Screens/Home/home.dart et Screens/Home/configurations_list.dart. Ils ne sont plus référencés par personne — HomePage est orphelin, CustomAppBar, main.dart:220, body.dart:122 et menu_page.dart:136 pointent tous sur HomePage3. Ils n'étaient gardés que pour le téléchargement, qui vient d'être porté. Ne les supprimer qu'après avoir joué §21 du test-plan sur device : ce sont les seules références du comportement d'origine si le portage se révèle incomplet.
⚠️ Deux verrues mises en lumière, pas corrigées. downloadPrompt annonce « 39,8MB » en dur dans les 10 langues et aucune donnée de poids n'existe côté DTO (la colonne weightMasonryGrid a été abandonnée en v3) : soit la taille entre dans l'export, soit le chiffre sort du texte. Et home_3.0.dart:904 porte isOnline = true; // Todo remove if not local test — la branche hors ligne ne s'exécute jamais (l'analyzer le confirme, dead_code juste après) : une visite téléchargée s'ouvre, mais l'accueil en mode avion passe quand même par le réseau.
/download et notifications de proximitéCodé dans les quatre repos, rien n'est committé :
IsQRCodeEnabled (défaut true), AppName (traductions), AppStoreUrl/PlayStoreUrl (modifiables par un SuperAdmin seulement, contrôlé côté API). Migration AddAppNameQrAndStoresToApplicationInstance.web.mymuseum.be (MDLF) restent lues ; nouvelle URL app.myinfomate.be/download/…. QR d'une autre instance refusé.app.myinfomate.be/download/{instance}/{config}/{section} — ou la section web directement pour une instance web seule./download (nom, image principale, liens stores), bouton de scan conditionné, nom de l'app sur l'accueil et dans l'onglet, suggestion de proximité GPS dans la page. Slugs download/demo/api réservés.meterZoneGPS). App ouverte → popup ; écran verrouillé pendant une visite → notification, dont le tap ouvre la section. Android : service de premier plan location ; iOS : mode d'arrière-plan location. App tuée : rien, par choix.⚠️ Reste à faire hors code :
web.mymuseum.be/{i}/{c}/{s} → app.myinfomate.be/download/{i}/{c}/{s}, pour les QR MDLF scannés à l'appareil photo. Le domaine n'est routé dans aucun compose des repos : à poser là où il est servi.app.myinfomate.be n'est pas encore déployé (carte 210) : les QR générés par le manager y mènent, les deux partent en prod ensemble.location à la revue Apple.Bug corrigé au passage : la popup beacon (BeaconArticleFound) lisait les maps JSON brutes de currentSections comme des SectionDTO et plantait.
Le web a pris de l'avance sur deux écrans : ArticleSection (deux volets côte à côte, lecteur audio ancré) et maintenant le dock de filtres de la carte. Les équivalents Flutter n'ont aucune règle d'orientation — grep Orientation sur mymuseum-visitapp ne ramène que quizz_page.dart.
⚠️ Une question précède l'écran : mymuseum-visitapp autorise-t-elle seulement le paysage ? Coder un rendu paysage pour une app verrouillée en portrait, ce serait du code mort. À lire dans main.dart avant de commencer.
tablet-app n'est pas une cible pour la carte : verrouillée en paysage (main.dart:105), c'est elle qui possède le panneau flottant d'origine, et le web n'a fait que l'emprunter. Y reste l'article deux volets, et l'harmonisation du panneau (verre, pastille de repli, compteur) avec ce que le web vient de poser.
Le time-to-first-audio est aujourd'hui la somme de tout : LlmClient.chat() retourne un future de réponse complète (pas de flux) et GeminiTtsEngine._synthesize() fait un generateContent unaire qui attend tout le PCM avant d'écrire le WAV. C'est ce trou que la nappe de réflexion bouche.
Le remède ne dépend d'aucune API nouvelle : découper result.reply en phrases, synthétiser et jouer la première pendant que les suivantes se préparent. Le time-to-first-audio tombe à LLM + TTS(1 phrase). Contenu dans GeminiTtsEngine — ni l'orchestrateur ni le backend ne bougent.
⏸️ Volontairement pas fait avec le reste du lot du 13/08 : c'est le seul chantier structurant des six, il mérite un feu vert séparé. ⚠️ À vérifier avant : :streamGenerateContent émet-il des chunks audio progressifs sur gemini-2.5-flash-preview-tts ? Test curl de 20 min — à faire, pas à supposer.
Remplacer wake_detected.mp3 par une phrase dans la langue et la voix du visiteur — « Oui, je vous écoute » — avec 2-3 variantes, un visiteur entendant l'ack des dizaines de fois sur une visite.
Retenu, mais explicitement après les tests : ça demande de générer et valider à l'oreille ~40 fichiers (2 voix × 4 langues × 5 phrases), et une partie du besoin que l'ack compense disparaît si le découpage par phrase fait baisser la latence. Décider avant d'avoir entendu le flux, ce serait décider à l'aveugle.
⚠️ Trois pièges déjà identifiés. pubspec.yaml:133 déclare assets/sounds/ mais les déclarations de dossier ne sont pas récursives en Flutter. Générer avec exactement le même voicePrompt que le runtime, sinon le timbre décroche entre l'ack et la réponse — et Gemini TTS n'est pas déterministe, prévoir plusieurs prises. ⛔ Et une règle de cohérence non négociable : sans GEMINI_API_KEY le moteur retombe sur la voix système Android — des acks en Sulafat suivis d'une réponse en voix système seraient pires que le bip. Acks actifs seulement si le moteur runtime est Gemini et que la voix correspond à guideVoiceId.
Ordonnancement à calibrer sur place : ouvrir le micro à player.duration - 150ms plutôt que de faire de l'AEC — sur Ray-Ban, micro et haut-parleur partagent la monture.
Supprimerait les trois maillons Whisper → LLM → Gemini TTS au profit d'un WebSocket permanent : flux micro brut en entrée (PCM 16 kHz), flux audio en sortie (24 kHz), sans jamais passer par du texte. Débloque trois choses que l'architecture actuelle ne peut structurellement pas faire : le barge-in (couper la parole à l'assistant), le VAD côté serveur (fin du timeout: 5 secondes en dur de _listenForFollowUp) et une latence de l'ordre de la seconde. Les voix prébuilt sont de la même famille — Viva/Marco survivraient.
Le point dur n'est pas l'audio, c'est le tool calling. Les outils (GetSectionDetail, RAG) vivent dans manager-service. Deux options : l'app parle directement à Gemini (latence minimale, mais logique métier déplacée dans le client et jetons éphémères obligatoires — on ne met pas une clé Gemini dans un APK de visiteurs), ou manager-service proxifie le WebSocket (clé au chaud, prompt et stats gardés, mais relais audio temps réel en C# avec sessions, reconnexions et backpressure). C'est l'option B qui a du sens, et c'est elle qui coûte cher.
⚠️ Le modèle de coût change de nature : plus à la requête mais à la session ouverte, avec des jetons audio bien plus chers. 80 visiteurs simultanés = 80 sessions. À chiffrer, les tarifs ne sont pas connus ici. ⚠️ Sessions à durée limitée, reprise à gérer sur réseau mobile en bâtiment de pierre. ⛔ Modèles en preview — après l'historique ElevenLabs → Gemini, y accrocher une fonction vendue serait imprudent. Verdict : spike chiffré d'une journée avant toute décision.
voice-latency-plan.md §3.2Trouvé en livrant D4 le 12/08, laissé volontairement hors périmètre du lot D. Deux blocs morts dans le même fichier :
1. ~130 lignes de classe commentée — /*class DownloadConfiguration { ligne 328 jusqu'au */ ligne 461. C'est ce bloc qui a fait croire au plan V1 que « les deux chemins de téléchargement divergent » : la ligne citée comme preuve vivait dedans. Un doublon commenté qui ressemble à du code actif coûte plus cher que pas de code du tout — il a produit un diagnostic faux.
2. cleanLocalResources, appelée nulle part — et non réactivable telle quelle : la table locale resources n'a pas de colonne configurationId, elle est globale à toutes les visites, et le paramètre configuration de la fonction n'est jamais utilisé. Deux issues : la supprimer, ou lui donner un périmètre en ajoutant la colonne. ⚠️ Conséquence à assumer si on supprime : les lignes DB des ressources retirées d'une visite ne sont purgées par personne. Impact réel faible — rien ne les lit pour le rendu, CachedCustomResource retrouve le fichier en listant le répertoire — mais la table croît sans borne.
Aujourd'hui aucune carte ne fonctionne hors ligne, et ce n'est pas une question de données : MapDTO porte déjà points, centerLatitude, zoom et iconResourceId, et les icônes sont téléchargées avec la visite. C'est le fond qui manque — les tuiles sont chargées en réseau, sans cache.
Le GPS, lui, n'est pas le problème : geolocator ^13.0.0 lit le GNSS, qui est autonome. Seule réserve à annoncer au client : sans A-GPS, le premier point peut demander 30 à 60 s sous couvert forestier.
Le chemin court existe déjà dans le pubspec : la version résolue est 2.8.0 (pas 2.0.0 comme le laisse croire la contrainte), et le paquet installé contient bien OfflineManager, TileStore, StylePackLoadOptions, TileRegionLoadOptions et TileRegion — vérifié dans le cache pub, pas supposé. On télécharge une emprise bornée au moment du téléchargement de la visite ; pour un domaine comme le Fourneau, quelques dizaines de Mo.
✅ Mapbox est désormais le fournisseur par défaut (04/09) : le null retombait sur Google dans map_page.dart (deux endroits) et map_config.dart. ⚠️ Effet de bord à surveiller — les sections carte existantes de MDLF et Fort Saint-Héribert dont le mapProvider est null basculent silencieusement de Google à Mapbox. Le token est déjà en place, en dur dans main.dart:60.
⚠️ Ce n'est pas gratuit, et le compteur est le mauvais : ce qui coûte n'est pas la surface de l'emprise mais le nombre de téléchargements — un tile pack par visiteur. La facture monte donc avec la fréquentation, c'est-à-dire avec le succès du client. Grille à vérifier chez Mapbox, ligne « tile packs » et pas seulement « map loads ». C'est exactement ce calcul que supprime v2/plan-illustre-plan.md.
⛔ Google est une impasse, et pas seulement techniquement. Le SDK Maps pour Android n'expose aucune API de tuiles hors ligne — les « zones hors connexion » sont une fonction de l'app Google Maps, pas du SDK intégrable. Et le code n'utilise même pas ce SDK pour les sections carte : il passe par flutter_map sur https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}, un endpoint non documenté dont la mise en cache est explicitement interdite. Conclusion : Mapbox devient le fournisseur du mode hors ligne, Google reste en ligne seulement.
⚠️ Tant que ce chantier n'est pas fait, SectionType.Map reste volontairement hors de offlineCapableSectionTypes (downloadConfiguration.dart) : l'ajouter donnerait une carte grise, ce qui est pire que ne pas la proposer. Une fois les tuiles packagées, c'est une ligne à ajouter dans cette constante.
Un musée de plein air ne distribue pas un fond OpenStreetMap, il distribue son plan dessiné. Conception complète, modèle de données et périmètre par repo : DOCS/v2/plan-illustre-plan.md. Rien n'est implémenté.
La décision qui structure tout : les repères se posent directement sur l'image, à la main, et c'est cette position qui fait foi — un plan dessiné n'étant ni à l'échelle ni orienté au nord, projeter des GeoPoint depuis leurs coordonnées les ferait tomber à côté des bâtiments. Le calage GPS ne sert qu'à afficher le visiteur, et devient donc facultatif.
⚠️ Deux affirmations de la première version de cette carte étaient fausses, corrigées le 04/09 : on ne projette pas les repères, et deux points d'ancrage ne suffisent pas — il en faut trois pour une transformation affine qui absorbe rotation et étirement, et pour pouvoir vérifier le calage au lieu de diluer l'erreur.
✅ Le calage se fait assis : cliquer le même angle de bâtiment sur le plan puis sur une carte réelle en regard. GeolocInputContainer ouvre déjà un FlutterLocationPicker (vraie carte, recherche d'adresse) — le composant est écrit, il faut le mettre à côté du plan. Plus besoin de prestation d'installation.
✅ Hors ligne gratuit : le plan est une Resource, une ligne dans GetReferencedResourceIds() et le pipeline existant l'embarque. Aucun tiers, aucun quota — contrairement aux tile packs Mapbox, facturés par visiteur.
⚠️ Conséquence web à trancher : visitapp-web reste sur Leaflet (lot E, W1), et mapProviderMobileOnlyNote le dit déjà au client. Pour un plan illustré c'est bloquant — le plan est le produit. Soit on porte le rendu en CSS dans la foulée, soit le plan Essentiel, web-only, n'y a pas droit.
Livrable qui se vend seul : le plan sans calage — téléversement, pose des repères, rendu, hors ligne. C'est le produit que les audioguides vendent depuis trente ans.
v2/plan-illustre-plan.md — conçu le 2026-09-04Relevé en livrant D2 le 12/08. Les trois lib/api/swagger.yaml (manager-app, mymuseum-visitapp, tablet-app) décrivent un ResourceDTO qui n'a ni sizeBytes (ajouté à l'époque de C1/C3) ni dateUpdate (D2).
Ce sont des artefacts de génération, et le client s'édite à la main : ils ne sont plus la source de vérité. Le risque n'est pas fonctionnel — rien ne les lit à l'exécution — mais ils contredisent le code en silence, et c'est exactement ce qui a coûté du temps sur le « fallback Voice → Mobile » du lot F : une doc affirmative et fausse. Deux issues : les régénérer une fois et les tenir, ou les supprimer et assumer que le Swagger en ligne est la seule référence.
STATUS.md §K5 — relevé D2Trouvé en livrant D2 le 12/08. DatabaseHelper.insert fait un UPDATE de la ligne entière quand l'id existe déjà : la boucle qui enregistre la charge de ressources (héritée de D1) repasse derrière la boucle de téléchargement et réécrit path avec la valeur par défaut de ResourceModel, soit "".
Sans effet aujourd'hui : personne ne lit cette colonne pour le rendu. Mais elle est NOT NULL et prétend porter le chemin local — le premier code qui s'y fiera trouvera une chaîne vide, sans erreur. Même famille que le dateUpdate effacé par la même boucle, corrigé en D2 parce que lui, il est lu. Deux issues : renseigner path dans les deux boucles, ou retirer la colonne et assumer que le répertoire fait foi.
Le site est édité par l'OCIM (Office de Coopération et d'Information Muséales) avec France Edition Multimédia — pas un annuaire SEO anonyme, mais la porte d'entrée que des musées francophones utilisent réellement pour chercher un prestataire. Deux mécanismes distincts sur le site : l'annuaire (fiche permanente, inscription annoncée comme gratuite, formulaire demandant coordonnées, contact et exactement trois thématiques — « multimédia » est la catégorie évidente pour MyInfoMate) et SOS Fournisseurs, où un musée décrit un besoin et reçoit des propositions.
Ce qui reste à investiguer, et qui décide de la valeur réelle : les demandes SOS sont-elles diffusées à tous les inscrits de la thématique ou faut-il une formule payante pour les recevoir ? Le tarif n'est affiché nulle part — à demander, pas à supposer (contact@francedit.com). Vérifier aussi ce que donne l'annuaire une fois dedans : combien de fiches en « multimédia », comment elles sont classées, et si une fiche gratuite est visible ou noyée.
⏸️ Pas avant que la prod soit stabilisée — même raison que la reprise de contact avec le Musée d'Ixelles : une fiche publique renvoie vers un produit qu'il faut pouvoir montrer. ⚠️ Le site est orienté France ; vérifier qu'un fournisseur belge y est accepté et bien référencé.
fournisseursdesmusees.com — annuaire & SOS FournisseursUne fois la prod stabilisée, pas avant : on n'ouvre pas une conversation en montrant un produit en cours de bascule. Premier temps 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 ensuite, pas dans le premier message. ⚠️ Rien n'est su de son rôle exact à ce stade : à ne pas supposer.
STATUS.md §1quinquies — Après la basculeLe rapport à la demande est livré (07/08, généré côté client). Reste l'envoi automatique, seule partie qui exige vraiment le backend : personne n'a son navigateur ouvert le 1er à 6 h. QuestPDF + Hangfire, config destinataires/fréquence, et une méthode d'envoi avec pièce jointe — les 10 méthodes d'IEmailService sont des templates figés sans attachement. ⚠️ QuestPDF exige libfontconfig1 + une police, absents du Dockerfile : ça marche en local sous Windows et casse dans le conteneur.
AuditLog n'a aucune purgeVisitEvent purge à 13 mois, VisitorQuestion à 90 jours, AuditLog à jamais. C'est un sujet RGPD et rien d'autre : la table porte UserId, et les valeurs avant-après d'une modification de User contiennent e-mail, prénom et nom. Ce n'est pas un sujet de volume — l'inondation par WeatherSyncService est coupée depuis le 12/08, la table croît désormais au seul rythme des éditions humaines. Donc à traiter au lot J, pas au lot F. Recommandation : 12 mois, uniforme, via un AuditLogPurgeService calqué sur VisitEventPurgeService — 12 et non 13, le 13 mois des stats existe pour comparer une saison à la précédente et le recopier ici serait du mimétisme. ⚠️ Reprendre le même verrou : purge inerte tant qu'Audit:RetentionDays n'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. Écarté : garder les Delete plus longtemps que les Create/Update, ça double les règles pour un cas qu'une sauvegarde couvre déjà.
SearchKnowledgePersona simple : réponses sourcées sur le contenu existant. Pas d'ingestion documentaire, pas d'OCR, pas de TTS — c'est ce qui le rend tenable en V1.
Livré le 10/08 : IngestionService (chargement des collections filles par sous-type, un jeu de morceaux par langue), serveur Hangfire dédié à 2 workers, garde AiTokensPerMonth > 0 — plan-starter est à 0 — et SearchKnowledge ajouté aux outils d'AssistantService plutôt qu'un /ask concurrent, avec sa règle dans les 4 prompts.
Joué contre la vraie base et la vraie API le 10/08 : 52 sections → 2150 morceaux en 53 s. Deux défauts que seule l'exécution pouvait montrer, corrigés dans la foulée. 1) Les gabarits de LanguageInit étaient indexés : les cinq premiers résultats d'une question en néerlandais étaient des NL - Title NL - Description, le bonus de langue suffisant à les faire passer devant du vrai contenu français — le cas même que le cross-lingue devait servir. 2) Le même paragraphe occupait trois des cinq résultats, au même score : DistinctBy(Text) avant le Take. ⚠️ Le post-filtrage HNSW reste non éprouvé à plusieurs instances — il n'y en a qu'une en base locale, soit le cas où le problème ne se voit pas.
Les deux angles morts du découpage sont fermés : HTML retiré avant l'embedding, et ligne trop longue recoupée à la fin de phrase — sans quoi un article dépassait l'entrée max de gemini-embedding-001 et l'échec emportait les 50 morceaux du lot.
Déclenchement unifié : l'Enqueue par contrôleur et un intercepteur EF ont coexisté une demi-journée. Retenu : SectionIndexingInterceptor seul — les 5 sous-contrôleurs totalisaient 30 SaveChanges et 0 Enqueue, donc ajouter 40 points d'intérêt à une carte ne réindexait rien.
✅ Tout le volet codable est livré le 13/08 : table d'agrégats de thèmes, job de regroupement, interrupteur de collecte par instance, mention affichée dans l'assistant. Voir « Fait récemment ».
Reste, et rien de tout ça ne s'écrit en Dart ou en C# : la relecture juridique du §8 des CGU (rédigé côté produit, jamais validé — priorité au §8.6 sous-traitants et au §8.5 transfert hors UE), la vérification des conditions réelles de Google (l'API Gemini offre-t-elle une résidence européenne ? un DPA est-il signé ? la réponse réécrit le §8.5), et la décision sur un DPA séparé — l'article 28 exige un acte écrit, et une commune ou un musée subsidié en demandera un en annexe.
⚠️ Deux traductions à commander avec cette relecture. La mention aux visiteurs n'existe qu'en FR/NL/EN alors que l'app porte 10 langues : les 7 autres replient sur l'anglais, faute de traduction relue. Traduire soi-même une mention de protection des données n'est pas une coquille d'interface qu'on rattrape après.
⚠️ Et une question ouverte au passage : activer WHISPER_API_KEY ajouterait OpenAI comme sous-traitant, que le §8.5 ne mentionne pas — il ne parle que de Google. La clé est vide aujourd'hui, c'est le moteur du device qui transcrit. À trancher avant de l'activer, pas après.
✅ L'échéance n'est pas la bascule mais la première question d'un visiteur réel — rien n'est collecté aujourd'hui, le délai de 90 jours n'a pas commencé à courir.
v1-plan.md — lot J · cgu-myinfomate.md §8 · mention-information-visiteurs.mdDeux écrans neufs compilent (flutter build web ✅) mais n'ont jamais été affichés dans un navigateur — le défaut récurrent de ce tableau. Le Guide IA (coquille à onglets, aperçu de conversation, carte de connaissance) et l'éditeur de parcours refondu (rail d'étapes, panneau, questions dépliées, enregistrement à la saisie).
Exige un manager-service qui tourne et une session ouverte : flutter run -d chrome, puis comparaison côte à côte avec claude design/guide-ia-screen.html et claude design/sectionparcours-refonte-flux.html. Pour le parcours, rejouer aussi test-plan.md §19.13 cas 0.
PdfPig avec cascade OCR Gemini (~0,03 $ / 100 pages). Word, PowerPoint, images. Legacy .doc/.ppt refusés à l'upload.
Le produit que les musées achètent depuis toujours, à coût marginal quasi nul. Aucun concurrent de cette gamme ne le fait.
Précisé le 28/08 — le volet commercial, absent du plan technique jusqu'ici. Audio par POI, pré-généré et embarqué dans le bundle offline : ce n'est plus « des textes sur un téléphone » qu'on vend mais un audioguide, face à des audioguides physiques à plusieurs milliers d'euros. La catégorie de produit change, donc l'argumentaire aussi.
⚠️ Facturer la génération, pas le stockage. Sinon la correction d'une coquille relance un cycle TTS complet sur toutes les langues, sans qu'un octet stocké n'ait bougé — le client paierait le mauvais compteur. ⚠️ Prérequis avant toute annonce sur le site : pilotable en self-service depuis le back-office ; à défaut, badge « Bientôt » et rien de plus. Un client qui doit nous écrire pour régénérer un audio n'achète pas une fonctionnalité, il achète une prestation.
Argument accessibilité — déficients visuels, FALC, seniors : souvent exigé en secteur public, parfois clé de subside. À rapprocher du chantier WCAG. Premier upsell identifié : le Fourneau Saint-Michel, une fois ses 20 bâtiments saisis.
v2/tts-pregenerated-plan.md · todo-features.md — TTS pré-généré (volet commercial)Premier geste, un mail à Holyiot (vendeur AliExpress) : reste-t-il du 22044-beacon-BP-3a-V1.0 ou une révision ultérieure ? Trois exigences à faire confirmer par écrit, pas déduites d'une fiche produit : piles AA ou AAA remplaçables (une CR2450 soudée ou non accessible disqualifie le modèle — on ne redéploie pas 20 balises pour changer une pile), UUID / major / minor configurables, et remontée de l'état de batterie. Sans réponse ou sans stock : même demande à Feasycom, sur la référence FSC-BP104D (IP67, 2×AAA, multi-slot iBeacon + Eddystone UID/URL/TLM) — la faire confirmer sur les trois mêmes points plutôt que sur sa fiche produit.
⚠️ La batterie doit passer par le major, pas par le minor — vérifié dans le code le 28/08 : l'app identifie le POI par le seul minorId (geo_beacon_trigger_service.dart:177 et configuration_page.dart:205, comparés à beaconSection.minorBeaconId). Encoder un niveau de batterie dans le minor casserait l'identification du point d'intérêt. Le major, lui, n'est lu nulle part dans les deux apps : c'est le seul champ libre. À demander tel quel au fournisseur — « batterie dans le major », pas « batterie dans major ou minor ».
⚠️ Et à faire confirmer avec : l'UUID d'émission. iOS ne voit que l'UUID codé en dur dans le service (voir la carte de test §9). Une balise non réglable sur cet UUID impose de rendre le champ configurable — c'est du code, donc à savoir avant de commander, pas à la réception.
Alternative propre si les fournisseurs bloquent sur le major : une balise multi-slot émettant iBeacon + Eddystone-TLM en parallèle, le TLM portant tension et température par construction. ⚠️ Mais c'est un second mécanisme de scan à écrire — le beacon_scanner actuel travaille sur les régions iBeacon, et sur iOS CoreLocation n'expose pas les données d'annonce brutes : il faudrait un scan BLE parallèle et une corrélation. Le major est le pis-aller qui ne coûte aucune ligne de code ; le TLM est plus juste et plus cher. Trancher au vu des réponses, pas avant.
Commander 2 ou 3 exemplaires avant tout volume, et exiger une photo du boîtier ouvert. Autres critères pour le plein air (Fourneau Saint-Michel) : IP67, couvercle à vis ou quart de tour, doc CE/RED — exigible en marché public. Piles lithium primaires (L91/L92) plutôt qu'alcalines : tenue au froid, pas de coulure ; contrepartie, leur décharge est plate, donc surveiller aussi l'âge de pose. Joint à regraisser à chaque changement, sachet déshydratant dans le boîtier, pas de fixation sur métal.
Le raccourci à garder en tête : le bois est transparent au BLE. Monter les balises à l'intérieur des bâtiments côté entrée règle la plupart des POI du Fourneau, et réserve l'IP67 aux points réellement isolés — moins de matériel exposé, moins de piles à changer sous la pluie.
todo-features.md — Beacons / géofencing (sourcing) · geo_beacon_trigger_service.dartLe défaut est de confiance, pas de design. La carte Pro annonce €99/mois et, juste dessous, setupFeeNote = « Frais de mise en place + engagement 12 mois (app mobile) » — sans aucun chiffre (translations.ts:272, repris tel quel en EN/NL/DE). L'acheteur budgète 1 200 €/an, puis découvre le setup en rendez-vous : c'est le pire moment pour l'apprendre. Remède : « à partir de X € selon le volume de contenu », dans les 4 langues. Priorité n°1 de la passe.
⚖️ Arbitré le 10/09 : pas de montant affiché. Thomas ne veut pas publier de plancher. Mesure de repli appliquée le même jour, dans les 4 langues : setupFeeNote devient « Frais de mise en place uniques, chiffrés dans le devis + engagement 12 mois », et la FAQ de la nouvelle page /tarifs précise ce que couvrent ces frais (configuration et publication sur les stores) et qu'ils sont chiffrés avant signature. L'acheteur sait donc qu'une ligne s'ajoute, et quand il en connaîtra le montant. Ce qui reste ouvert : si un plancher est un jour décidé, c'est cette carte qui le porte.
Secondaire, même passe : badge « IA incluse » sur Premium — le badge « Recommandé » reste sur Pro (HomeClient.tsx:525), il ne se déplace pas ; regrouper assistant IA + traduction automatique + audio sous un intitulé unique type « Studio IA » plutôt que trois lignes de features indistinctes ; remplacer le stockage en GB (1 / 15 / 50 GB, en dur dans HomeClient.tsx et SegmentPageClient.tsx) par une métrique que l'acheteur sait évaluer : POI / parcours / langues / sites. Personne ne sait ce que pèsent ses contenus en gigaoctets.
⚠️ Le « Bientôt disponible » sur Essentiel n'existe pas dans le code — vérifié le 28/08 : le seul badge « Bientôt » de la landing est sur le 4e mode de déploiement (XR, HomeClient.tsx:154), et la carte Essentiel pointe déjà vers /signup. Ce qui bloque réellement Essentiel est le déploiement de app.myinfomate.be (carte dédiée dans ce tableau). L'arbitrage est donc : déployer, ou ajouter un badge honnête en attendant — pas retirer un badge absent.
Audit du 09/09 : 21 actions classées par rapport impact/effort. Faites le 10/09 et vérifiées dans le HTML servi : titres h2/h3, schema Organization ancré en Belgique avec la TVA, trois offres dans le schema, les 9 modules de la home enfin dans le HTML, liens morts, llms.txt en français, prérendu statique rétabli (58 pages, le layout racine est passé dans [lang]) et une page /tarifs avec FAQ tarifaire. Stagé, pas committé.
/assistant-ia est écrite le 10/09, en français seulement (~850 mots, 6 FAQ, schema FAQPage) : en attente de relecture, puis traduction NL/EN/DE. Les autres langues répondent 404, c'est volontaire.
Vocabulaire acheteur injecté dans les 6 segments FR le 10/09 : une FAQ RGPD partout, une FAQ marchés publics dans les 5 segments publics, une FAQ accessibilité qui assume l'absence de certification Access-i. ⚠️ Pas encore traduit en NL/EN/DE.
Ajouté ensuite le même jour : FAQ de la page d'accueil (8 questions ×4 langues + FAQPage), BreadcrumbList sur toutes les pages profondes, et de vraies dates dans le sitemap à la place de la date de build.
Puis 42 items de FAQ traduits en EN/NL/DE (parité dans les 4 langues) et images recompressées : 1,1 Mo économisé, sharp en installation temporaire seulement. ⚠️ public/google-maps-route.png (699 Ko) n'est référencé nulle part. Puis captures d'écran renouvelées avec les vraies vues de la V3 (accueil bento sombre, fiche article, liste des sections) et textes alternatifs localisés dans les 4 langues.
Le 10/09 également : trois pages en 4 langues — /marches-publics, /borne-kiosk, /visite-hors-ligne — un gabarit partagé ContentPage.tsx, une colonne « Solutions » dans le pied de page, et sharp en dépendance (mesuré : 64 Ko de JPEG servis sans lui, 26 Ko de WebP avec — ⚠️ à confirmer au premier build Docker).
Suite : pages comparatives — preuve à mettre en avant, confirmée le 10/09 : Visit Namur a été gagné sur cahier des charges publié, mais ⚠️ aucun document d'appel d'offres n'existe encore, donc ne rien promettre. Les frais de mise en place restent sans montant, par décision de Thomas (carte 180).
LinkedIn : vitrine MyInfoMate créée sous Unov, fiche produit soumise. Huit posts rédigés en deux vagues : ⚠️ ceux qui poussent à l'inscription attendent la bascule prod.
myinfomate-landing/audit-seo-geo.md §0 · DOCS/linkedin-posts-lancement.md« Un parcours de 45 min pour familles ». Marche sur le smartphone du visiteur, aucun hardware. Vendable aux villes comme balade sonore IA.
plan-import-ia-stats-subsides.md §3Les CGU sont en ligne depuis le 10/09 — publier valait mieux que faire cocher « j'accepte » sur un lien mort — mais elles n'ont jamais été relues par un juriste, ce que le document source dit lui-même depuis avril. Deux points appellent un œil professionnel : le §8 (RGPD), réécrit le 11/08 quand le guide IA s'est mis à enregistrer les questions des visiteurs, et le §9 (SLA à 99 %), qui est un engagement chiffré tenu par une infrastructure mono-serveur.
Deux sources à garder synchrones : DOCS/cgu-myinfomate.md fait foi, la page Next en est le rendu. Toute correction du juriste passe par les deux.
⏱️ À faire avant d'ouvrir l'inscription self-service à de vrais clients payants, pas avant la mise en prod technique. Prévoir aussi le contrat de traitement des données (DPA) qu'un acheteur public demandera — il n'existe pas encore.
DOCS/cgu-myinfomate.md (note d'en-tête) · myinfomate-landing/src/app/(legal)/conditions-generales/page.tsxPlusieurs personas, assignables à une section ou une configuration. Le modèle part en V1 (liste, pas champ unique) ; le comportement attend — qui répond depuis l'accueil, et sur quel périmètre de contenu, est une vraie question de conception.
v2/tts-pregenerated-plan.md — PersonaConfigapp.myinfomate.beL'app existe : build ✅, 13 types de section rendus, assistant IA inclus depuis le 06/08. Il ne manque que le Dockerfile et l'entrée Traefik dans le compose. Le plan Essentiel vendu en self-service est web-only — sans ce déploiement, l'onboarding vend un produit inaccessible.
architecture-web-saas.md — À faireExtension web de tablet-app pour les clients ne voulant pas d'installation native. Reporté en V2 le 07/08 : aucun client demandeur. ⚠️ Le second motif est tombé le 12/08 — « la cible native ne compile même plus » n'est plus vrai, l'APK est produit depuis K1.
Arbitré le 12/08 : paysage seul en V1, portrait en V2. Une seule maquette à dessiner, les six règles de la maquette K3/K7 s'étendant aux neuf autres types. ⚠️ Mais pas « paysage en dur » : le portrait étant un objectif V2 assumé, les décisions liées à l'orientation (sens de partage, côté du rail média, position de la carte) passent par un point de décision unique et nommé qui renvoie « paysage » pour l'instant. Surcoût V1 nul, et la V2 devient un branchement au lieu d'une réécriture. Pas de maquette portrait, pas de if portrait spéculatif.
a) L'écran principal en bento — le seul code vraiment neuf, et moins cher que prévu. ⚠️ Vérifié le 12/08 : le kiosk n'a pas le bento — main_view.dart:403-405 est un GridView à crossAxisCount fixe, toutes les tuiles de même taille, et myinfomate_layout n'est pas dans son pubspec.yaml. ✅ Mais la donnée est déjà là : les spans vivent sur AppConfigurationLinkDTO.gridColSpan/gridRowSpan, donc par canal, réglables dans manager-app — et K2 a déjà posé TabletAppContext.currentAppConfigurationLink. Il reste à ajouter la dépendance locale et appeler bentoLayout() comme home_3.0.dart:538. Bénéfice de bord : l'aperçu de manager-app cesse de montrer au client un bento que sa borne ne rend pas.
b) Inventaire par type, tranché le 12/08. À revoir : Slider (le plus faible), Weather (retouche légère). À garder comme référence : Agenda — les autres s'alignent dessus. Réputés bons, à confirmer à l'œil : Game, Web, Map, Video. Non tranchés, à regarder dans la même passe : Menu, Quiz, PDF. Article et Event sont déjà couverts par K3/K7 et ne se rouvrent pas — mais sont à repasser sur le point de décision d'orientation, sinon la V2 aura deux idiomes.
c) Vimeo — un vrai trou, passé en V1. ⚠️ SectionVideo.cs:21 annonce « URL YouTube/Vimeo » et le kiosk a bien son video_viewer_youtube.dart — mais vimeo n'apparaît nulle part dans le Dart des deux apps visiteur : une URL Vimeo tombe dans le lecteur de fichier et ne lit rien. Lecteur à écrire sur le modèle de l'existant, branché aux deux mêmes points de dispatch. ⚠️ Les trois fichiers concernés existent en double, une copie par app : écrire une fois, copier sciemment. ⚠️ Comme YouTube, une URL reste indisponible hors ligne (SectionVideo.cs:25-27) — c'est voulu, ne pas le « corriger ».
⚠️ Non bloquant pour la bascule, contrairement à K2/K3/K4 qui réparaient un contrat d'API cassé : c'est du confort visuel, publiable après K6 au prix d'une seconde publication d'APK qui ne coûte rien sur un parc géré. Premier poste à sacrifier si la date serre. 4 à 6 jours, dont ~1 de maquette.
v1-plan.md lot K — K9 · maquette kiosk-paysage-article-event.htmlNouveau type de section : le lieu compose un questionnaire (texte, texte long, choix unique, choix multiple, note 1-5), le visiteur y répond anonymement, l'admin lit les réponses agrégées dans un onglet de la section, avec export CSV. Enquête de satisfaction, livre d'or, sondage d'exposition.
Reporté en V2 le 07/08 : purement additif, aucun impact sur le schéma existant. Analysé dans le code le 15/09 — plan complet dans v2/section-form-plan.md, décisions arrêtées, ne pas refaire l'analyse.
Ce qui remonte aujourd'hui du visiteur est uniquement dérivé (VisitEvent, VisitorQuestion) : c'est le premier mécanisme qui permet à un lieu de poser une question. La moitié de la plomberie existe déjà — StatsController.TrackEvent pour l'écriture anonyme, SectionQuiz/QuizQuestion pour la structure, VisitorQuestionPurgeService pour la rétention.
Trois fronts visiteur, kiosk compris. Piège propre à tablet-app : sur borne fixe le visiteur suivant hérite de l'écran du précédent — reset après soumission et sur inactivité. Et un piège backend : GetEmbeddableText doit indexer les libellés de questions mais jamais les réponses, sinon le guide IA récite les avis des visiteurs précédents.
Modèle ArAnchor, microservice Node de compilation des .mind, et remplacement du scanner visiteur par une WebView unifiée QR + image. Le plus lourd des trois, fort effet démo. Reporté en V2 le 07/08 — ni la migration ni la mise en prod n'en dépendent.
Donner l'IA à un client Pro se fait en SQL : AiTokensPerMonth n'est dans aucun DTO, IsAssistant est à poser deux fois (instance et ApplicationInstance), et le rattrapage d'indexation ne partant que depuis UpdateInstance en C#, il faut penser au bouton de relance. Exposer le quota dans InstanceDTO ramènerait ça à un formulaire. Reporté en V2 le 12/08 : la V1 n'a besoin d'aucun add-on — les 4 clients de la bascule sont affectés à leur plan, et l'activation manuelle reste un geste interne, fait une fois, sur une base qu'on contrôle. À faire d'un bloc avec l'endpoint de mise à jour d'ApplicationInstance (canaux activables) : c'est le même écran.
Troisième add-on à poser dans le même écran, décidé le 31/08 : l'add-on « Contenu immersif » à partir de 70 €/mois (image 360 + vidéo 360 + modèles GLB). Techniquement c'est le patron d'IsAssistant à l'identique — Instance.HasImmersiveContent plus un relèvement de StorageQuotaBytes, l'Instance recopiant déjà localement les valeurs du plan (Instance.cs:87-98). Pas de nouvelle ligne de plan, pas de table de jointure, ~1 j. ⚠️ Mais l'enveloppe de stockage n'est pas optionnelle : un client Pro a un quota Pro et une vidéo 360 de 5 min pèse des Go.
✅ 15/09 : lots 5 et 6 codés, calibrage §9.4 fait. Mention IA dans le fichier (XMP IPTC) + gravure en option. Calibrage sur 61 générations (Fourneau en gravure, Fort en aquarelle) : trois défauts corrigés avant tout client — négatif en double négation, titre de fiche recopié en légende, date d'un objet effacée. Coût réel fal : 0,03 $ l'image + 0,015 $ par référence ; unité arrêtée : 1 crédit = 0,015 $ de coût, vendu avec marge (≈ 0,05 €). Planches dans outputs/studio-calibrage-2026-09-15/. Reste : jugement visuel de Thomas, test du conservateur (gate lot 4), push de la branche.
🛠️ Lots 0 à 4 codés le 13/09 sur la branche studio (manager-service, manager-app, mymuseum-visitapp) : ingestion serveur, crédits, identité visuelle (menu Studio), génération fal.ai derrière IGenerationProvider (fournisseur interchangeable), onglet Générer dans le sélecteur de ressource, image d'étape rattachée à une ressource. Reste avant le gate : bucket de dev + CORS, clé fal.ai (Studio__Fal__Key), migrations appliquées, calibrage et prix (§9.4), puis le test du conservateur.
✅ Plan consolidé le 13/09. Le §8 donne, pour les lots 0 à 4, les prérequis, les étapes, les vérifications et le gate. Le §9 contient un catalogue v0 (8 styles, règle de préambule, 3 gabarits) à calibrer en fin de lot 3. Six décisions ont été prises : upload par URL signée + ingestion serveur (bucket fermé aux clients, gros fichiers hors VPS), MVP limité à « depuis le texte » et « depuis une image » (objets et lieux), prix différés avec Grant manuel, une recharge qui prolonge tout le solde, plafonds d'upload prudents, catalogue calibré au lot 3. Le webhook fal.ai est signé en ED25519, pas en HMAC.
Le produit n'est pas l'accès aux modèles, c'est la cohérence. N'importe qui peut générer une image ailleurs. La valeur : qu'un conservateur qui ne sait pas prompter obtienne, en trois champs, une image raccord avec les quarante autres du même parcours. Une VisualIdentity par instance (style verrouillé, époque, palette, exclusions, 1-5 images de référence) + surcharge optionnelle par configuration — le parcours Halloween a sa propre identité, exactement comme Configuration porte déjà ses PrimaryColor/Languages. Injectée côté serveur dans chaque prompt, jamais retapée. Boutons « Générer » dans l'éditeur de contenu, pas dans un playground ; gabarits métier, prompt libre en mode avancé seulement.
⛔ Prérequis dur, carte séparée : le backend ne sait pas écrire dans le bucket. Sans le lot 0, rien du Studio ne tourne.
⚠️ Quatre pièges déjà tranchés. Une URL Firebase à jeton est publique — un brouillon dans pictures/ serait servable au visiteur : préfixe studio-drafts/ + proxy authentifié. Le quota IA existant ne tient pas sur des jobs parallèles (CheckQuota avant / incrément après : dix jobs Hangfire passent avant le premier débit) → réservation en deux temps sur un CreditLedger. Le plafond dur n'arrête pas le stagiaire — c'est un plafond par utilisateur et par jour qu'il faut, pas un plafond organisation. Et GuidedStep.ImageUrl est une URL, pas un ResourceId : les images générées de l'escape game ne partiraient pas offline (GuidedStep.cs:74) — migration à faire dans le lot MVP.
🚫 Pas de watermark gravé dans les pixels (tranché le 01/09) : irréversible, et laid sur une illustration de musée. L'AI Act exige que le visiteur soit informé, pas que l'image soit tatouée. Provenance complète en Resource.AiProvenance (modèle, prompt effectif, refs, auteur, version d'identité, rightsHolder = le client) + badge « image générée par IA » dans l'UI visiteur, traduit dans les langues de la configuration. Gravure en option par instance pour l'institution qui l'exigerait par écrit.
MVP = escape game, images seulement. Faible enjeu scientifique, fort besoin de cohérence, gros volume. Crédits en monnaie dédiée, distincte des tokens IA — griller son quota d'images ne doit pas priver les visiteurs du guide. Catalogue de modèles en base, pas dans le code (fal.ai comme passerelle unique : FLUX.2 pro, Nano Banana Pro, Recraft V3 ; Kling/Veo et Meshy/Tripo plus tard). 🧪 Le test de réussite se joue à la fin du lot 4 : installer un conservateur devant l'écran et le laisser produire 10 images cohérentes seul. Si je dois prompter à sa place, l'UX a échoué — les lots suivants n'y changeront rien. Gros volume de test à prévoir.
👤 Trois entrées distinctes dans le panneau de génération, à ne pas confondre (précisé le 02/09) : l'identité visuelle donne le style du projet, l'image source donne cet objet-ci (la vraie pièce de la collection, avec un curseur de fidélité : rien / le sujet / sujet + pose / cadrage exact), et le personnage donne cette figure récurrente — « c'est Léon qui est dans le donjon ». Le canon part en référence et ses paramètres (époque, costume, âge) sont injectés en texte : une image de référence seule ne porte pas « capote d'officier ».
⚠️ Deux personnages dans une image coûtent la cohérence du style. Le plafond de références est celui du modèle (10 pour FLUX.2) : un personnage à 4 vues + 4 références d'identité + 1 source = 9/10, mais deux personnages font tronquer l'identité — donc le style dérive précisément sur l'image la plus ambitieuse. Priorité serveur au canon, troncature de l'identité ensuite, jauge visible dans l'UI et avertissement dès le deuxième. Un personnage par image, et on compose la scène autour de lui.
🖼️ Attention aux personnes identifiables. « Modifier une image » sur la photo d'une personne réelle, chez des institutions publiques, c'est du traitement de ressemblance : consentement écrit et tracé. La réponse produit est de rediriger vers les personnages — un avatar généré n'est le portrait de personne.
v2/studio-plan.md — §8 (exécutable lots 0-4), §9 (catalogue v0), décisions 22-23Aujourd'hui le loader est une image fixe : Configuration.LoaderImageUrl / LoaderImageId, rendus par loading_common.dart dans les deux apps Flutter et par SplashScreen côté web. Le besoin : que le client compose un loader animé depuis le manager, sans fournir de fichier.
Décision : pas de format de fichier animé. On transporte des paramètres. Un nouveau champ de configuration porte {preset, couleurs[], logoResourceId, vitesse} — quelques centaines d'octets — et chaque front implémente nativement les 5-6 mêmes presets : AnimationController en Flutter, CSS/Canvas en Next.js, animation Unity côté casque. Le preset n°1 existe déjà et tourne : manager-app/lib/Components/loader_animated_pieces.dart (6 pièces vectorielles, onde d'opacité, rotation, flottement) — il suffit d'en sortir les couleurs et les timings, aujourd'hui en dur.
Les couleurs sont pré-remplies depuis VisualIdentityDTO.palette, qui existe déjà. Aucun crédit Studio débité : le rendu est local au navigateur, rien ne passe par un modèle.
⛔ Le SVG animé est écarté : flutter_svg ignore <animate>, SMIL et les keyframes CSS — il rendrait une image figée dans les trois apps Flutter — et Unity n'a aucun rendu SVG. Un seul des quatre fronts l'afficherait.
⛔ Lottie est écarté à ce stade, malgré son écosystème. Format de fait et non norme, gouverné par une société unique (LottieFiles) sur une spec récente ; surtout, aucun runtime moteur de jeu n'est listé sur le site officiel. Les deux options Unity sont fragiles : thorvg.unity (Android arm64 annoncé, mais 26 commits et 14 étoiles, successeur de Lottity archivé en 11/2025) et unity-rlottie (plus fourni mais en experimental, avec un ticket ouvert sur une texture NULL en build Android). Les deux rastérisent en Texture2D à chaque frame côté CPU — inacceptable sur Quest à 72-90 Hz pour un écran de chargement.
Porte de sortie assumée : le jour où un client veut apporter sa propre animation faite par une agence, on ajoute Lottie sur les 3 fronts 2D seulement, avec le PNG de première frame en repli côté VR. Le champ loader accepte alors soit des paramètres, soit une URL — rien de ce qui est fait ici n'est à refaire.
Coût réel : les presets. Chacun est du code dans 4 dépôts, à tester sur les 4 fronts. En sortir 5-6, pas 20 : au-delà le client ne choisit plus, il se perd. Stocker les paramètres permet la réédition six mois plus tard.
⚠️ Pas un bloquant V1 — le loaderImageUrl actuel fait le travail avec un PNG. À ouvrir après la bascule prod.
Lots 1 à 5 codés le 04/09 (flutter analyze sans erreur, flutter build web passe ; rien de vérifié à l'écran). Pilote select_resource_modal : coquille standard, corps souple, plus de double défilement, showValues mort supprimé, largeur 0,85 conservée. Lot 1 : text_field_container sans width: double.infinity ni rayon 29, compteur discret dans rounded_input_field. Lot 2 : libellé au-dessus et plus d'auto-centrage dans string_/multi_string_/resource_/check_input_container. Lot 3 : grille responsive de sections, réordonnancement par Draggable/DragTarget (ReorderableListView ne fait pas de grille, et pas de dépendance nouvelle), tuile « Ajouter » à la place du bouton vert. Lot 4 : les deux écrans de détail en EditorHeader + EditorColumns + Pane (nouveau Components/pane.dart), _buildFooter supprimé, le QR devient le rail « Diffusion ». Lot 5 : grille de types dans new_section_popup, modal de traduction en rail de langues + IndexedStack, et multi_input_modal réduit à une redirection isHTML: false. 24 clés i18n ajoutées en FR/EN/NL.
🐛 Le bug des catégories est confirmé et corrigé : showCreateOrUpdateCategories recevait un newValues vide de l'appelant et le renvoyait tel quel — valider sans rien toucher effaçait les catégories. Le tampon part maintenant des catégories existantes, et les libellés en dur sont passés par AppLocalizations.
Lot 6 entamé : Components/collection_editor.dart existe — liste ordonnée à gauche (réordonnancement par poignée), élément édité en place à droite, ligne « Ajouter » en fin de liste, suppression dans l'en-tête du détail. Trois types migrés : Slider et Article (via un ContentFields partagé, extrait de showNewOrUpdateContentSlider) et PDF (le bloc figé 70 × 440 disparaît). Les filtres d'envoi au backend sont conservés à l'identique (contenus sans ressource exclus, champs d'article recopiés).
⚠️ Trois fichiers sont devenus du code mort mais ne sont pas supprimés : pdf_file_input_container.dart, pdf_list.dart, new_update_pdfFile.dart. Ils portent des modifications non committées d'un autre lot — les effacer perdrait ce travail. À supprimer une fois l'arbre propre.
Lot 6 terminé pour le gabarit B. Décision prise le 04/09 (artifact 4af75171) : CollectionEditor reçoit un mode distant (RemoteCollection : create / delete / reorder asynchrones, voile de chargement, retour à l'état d'avant si l'appel échoue). Quiz l'utilise : la modale de question disparaît, les messages de score remontent en tête de carte, les réponses gardent QuizzResponseList tel quel. Menu garde la navigation vers sub_section_edit_screen — une sous-section est une section complète — et prend la grille de tuiles, extraite en SectionGrid et partagée avec les sections d'une configuration. Parcours : liste qui suit son contenu, les deux SizedBox(height: 500) de event_config et section_parcours_config retirés, éditeur à fenêtre unique inchangé.
🐛 Le rang en double est corrigé dans Quiz et Menu : ils n'écrivaient que l'order de l'élément déplacé, les autres gardaient le leur. La liste entière est renumérotée et envoyée en un Future.wait, comme le faisait déjà Parcours.
Agenda n'a pas été migré, et c'est délibéré : ses événements sont groupés par langue (_displayGroups, pastilles de langue quand un groupe en compte plusieurs) et n'ont pas d'ordre — une liste ordonnée à édition en place perdrait le groupement. Il reçoit ce qui lui revient : hauteur pilotée par le contenu (le height: 600 saute), lignes au système de tokens, modale conservée.
Gabarit C — Jeu est fait, Map et Événement sont dégrossis. game_config devient champs à gauche + canevas à droite : le canevas montre le découpage réel de l'image en rows × cols, seule décision que le formulaire ne montrait pas. Au passage, son TabBar n'en était pas un — les deux onglets construisaient le même formulaire et ne servaient qu'à écrire gameType : c'est devenu un choix à deux valeurs, et le SizedBox(height: 650) a disparu. geoloc_input_container passe à la coquille standard et affiche les coordonnées retenues au lieu d'un bouton « Changer la localisation », la carte prenant la hauteur disponible. Côté map_config : en-tête en grille de tokens, height: 700 et height: 70 retirés. Côté event_config : height: 600 retiré, la liste des blocs suit son contenu.
Reste : le canevas de Map et d'Événement — et il est maintenant maquetté : DOCS/claude design/refonte-gabarit-canevas.html (diffusion : artifact caf400b5). Liste des points, fiche, carte côte à côte ; les points non sélectionnés restent visibles en gris ; MapGeometryPicker sort de son dialogue pour devenir le canevas (déplacement, pas écriture) ; les informations pratiques d'un point (5 champs sur 9) sont repliées derrière un compteur. Absorbe showNewOrUpdateGeoPoint (362 l.), geometry_input_container et showNewOrUpdateMapAnnotation (182 l.). Une question produit reste ouverte : que montre le canevas d'Événement quand baseSectionMapId est nul — carte vide, ou invitation à choisir un plan d'abord (ma proposition).
Étape 1 du canevas faite le 04/09 : Components/map_canvas.dart — le dessin est sorti du dialogue. Il reçoit une géométrie, la rend modifiable, rend la main à chaque changement (pas de bouton « Enregistrer » : c'est l'écran qui enregistre), et affiche les autres tracés en fond via MapCanvasGhost. Barre d'outils compacte sur la carte, pied qui donne les coordonnées et ce qu'on modifie, bouton « agrandir » optionnel. MapGeometryPicker n'est plus qu'une coquille autour de lui, donc etape_fields et showNewOrUpdateEventAgenda ne bougent pas. Au passage, ses libellés « Ligne » / « Polygone » en dur sont passés par AppLocalizations. Taille tranchée : 420 px par défaut + mode agrandi, pas une interface centrée carte — un point porte neuf champs traduits, la carte n'en porte qu'un.
Étapes 2 à 4 faites : le gabarit C est en place dans Map. Map/geo_point_editor.dart — liste des points à gauche, fiche au centre, carte à droite ; les points non sélectionnés apparaissent en fond ; le mode agrandi replie la fiche et garde le rail. Les neuf champs traduits sont rangés par fréquence : titre, catégorie, description et image visibles, les cinq informations pratiques repliées derrière un compteur. map_config passe de 937 à ~510 lignes : la modale showNewOrUpdateGeoPoint n'est plus appelée, getElement et son boxDecoration sont supprimés, et recherche + catégories filtrent désormais la liste et la carte par un seul calcul. Les points sont créés, modifiés et supprimés directement par l'API (sectionMapCreate / Update / Delete), la suppression passant par la confirmation destructive.
Étape 5 faite : le canevas d'Événement. Les annotations passent dans l'éditeur de collection en mode distant, avec le canevas dans le panneau d'édition. Plan par défaut retenu (décision Thomas) : le premier plan disponible est pris quand baseSectionMapId est nul, plutôt que de bloquer. 🐛 Trouvé en chemin : MapAnnotationDTO portait déjà un champ geometry que l'ancienne modale ne remplissait jamais — elle ne réglait que geometryType. Une annotation déclarait donc une forme sans jamais recevoir de coordonnées ; le canevas les lui donne. Enfin showMultiStringInputAndResourceHTML est passé à la coquille standard : plus aucun dialogue de ce chantier sur l'ancienne.
Plan de test : DOCS/test-plan-refonte-config-section.md, 14 sections. Rien n'a été vérifié à l'écran — flutter analyze propre et flutter build web qui passe, c'est tout.
Reste : supprimer le code mort une fois l'arbre git propre — showNewOrUpdateGeoPoint, showNewOrUpdateMapAnnotation, pdf_file_input_container, pdf_list, new_update_pdfFile, listView_card_subSection, new_update_question_quizz ; et geometry_input_container quand etape_fields et showNewOrUpdateEventAgenda auront leur canevas.
showMultiStringInputAndResourceHTML sur l'ancienne coquille, et le code mort à supprimer une fois l'arbre propre : pdf_file_input_container, pdf_list, new_update_pdfFile, listView_card_subSection, new_update_question_quizz.height: 70, 100) dans les écrans non repris — showNewOrUpdateGeoPoint, showNewOrUpdateEventAgenda, sub_section_edit_screen, new_configuration_popup.
Maquette prête et complète : DOCS/claude design/refonte-configuration-sections.html (diffusion : artifact 4d0c2ad6). Elle contient le diagnostic, les deux écrans, les 3 gabarits qui couvrent les 13 types de section, la coquille de dialogue, et deux dialogues dessinés — traduction et choix du type. Rien à décider côté design : tout est tranché.
Le blanc ne vient pas des écrans mais de 4 composants partagés. text_field_container.dart impose width: double.infinity + rayon 29 ; string_input_container.dart met le label à gauche dans un Row ; multi_string_input_container et resource_input_container se centrent eux-mêmes ; section_reorderList.dart réserve 0,3 × hauteur pour une liste horizontale de cartes 150×150. On les corrige une fois, les 13 types en héritent.
⚠️ Aucune direction visuelle à inventer : constants.dart porte déjà le système (kSpace1..8, kRadiusInput 5 / Card 8 / Shell 10 / Pill, kTitleCard, kLabelField, kTextHint) et confirmation_dialog.dart + resource_picker.dart l'appliquent déjà. Ces deux écrans sont simplement antérieurs au système.
Premier pas conseillé : select_resource_modal.dart, pas les composants partagés. Un fichier, ~15 lignes, zéro ligne dans ResourcesScreen, et ça corrige un vrai bug — AlertDialog dispose de hauteur − 48 mais le contenu en demande 0,85 × hauteur plus titre plus actions, d'où un défilement dans un défilement. Ça sert de pilote avant de toucher aux composants qui irriguent 30 écrans. ⛔ Ne pas toucher à la largeur 0,85 : la règle du rail de facettes de v1-mediatheque-plan.md §3.3 est écrite sur cette hypothèse et déjà codée.
⚠️ Deux pièges qui ne sont pas du visuel. D'abord, manager-app n'a que 3 tests, aucun sur ces composants : vérification manuelle, et il reste des conteneurs à hauteur figée (height: 70, 100, SizedBox(height: 250)) autour — ce qui rentrait juste débordera. Ensuite un chemin d'écriture est câblé dans les composants : section_detail_screen.dart:270 déclenche un save(true, …) depuis MultiStringInputContainer.onGetResult, et la ligne 381 un save(false, …) depuis la case iBeacon. À ne pas perdre ni dédoubler.
⚠️ Le piège Quill (remonté de mémoire par Thomas, confirmé dans le code). Ça marche aujourd'hui parce que l'empilement vertical monte toutes les langues : _controllers est bâtie une fois dans initState (translation_input_container.dart:52) et libérée seulement dans dispose. Le rail de langues de la maquette casse cette propriété s'il reconstruit l'éditeur à chaque bascule. Règle : map construite une fois pour toutes les langues, bascule par IndexedStack/Offstage, jamais de TabBarView (construction paresseuse, enfants lâchés hors écran sans AutomaticKeepAliveClientMixin). Trace d'une tentative abandonnée : translation_tab.dart existe en TabBar, son appel est en commentaire dans multi_input_modal.dart:46-51. À retester en priorité : la traduction IA (ligne 142) et « appliquer à toutes les langues » (ligne 183), les deux chemins qui libèrent et recréent les contrôleurs.
Deux fusions et un ménage décidés dans la maquette : multi_input_modal.dart est le jumeau sans HTML du modal de traduction (même travail, deux implémentations) → une coquille, deux corps, Quill si isHTML et champ simple sinon ; les 8 showNewOrUpdate* et les 2 sélecteurs géo disparaissent dans les gabarits B et C ; showValues est mort en double (select_resource_modal.dart:64 et multi_input_modal.dart:186, appel commenté ligne 53).
⚠️ Conflit résolu avec l'audit — security/audit-manager-app.md recommandait d'extraire un FormDialogScaffold pour les 15 occurrences des showNewOrUpdate*. Or la refonte les supprime : les extraire d'abord, ce serait refactoriser du code à jeter. Ordre imposé : absorber d'abord (gabarits B et C), extraire ensuite sur ce qui survit — traduction, nouvelle section, nouvelle configuration, sélecteur de couleur, fichiers PDF, catégories. L'audit et STATUS.md ont été annotés en ce sens le 2026-09-04.
🐛 Trouvé en passant, à vérifier : pdf_file_input_container.dart et category_input_container.dart sont un copier-coller — mêmes dimensions, même structure — mais le second a ses libellés en dur (« Annuler », « Valider ») et son onGetResult(result) commenté. Une catégorie de carte modifiée ne remonte peut-être pas à la volée. Lecture de code, pas testé.
🛠️ Code livré le 13/09 (branche studio) : URL signée, ResourceIngestionService, ImageSharp, upload avec progression dans manager-app, SDK Firebase retiré. Reste l'infra ci-dessous, puis la checklist du §8.
IResourceBlobService n'expose que DeleteAsync : tout l'upload est fait par le navigateur en direct, à deux endroits de resources_screen.dart. Or le Studio et le TTS pré-généré produiront côté serveur.
⚠️ Trou de sécurité actuel, indépendant du Studio. manager-app n'a pas de Firebase Auth (firebase_storage sans firebase_auth) et écrit pourtant dans le bucket : les règles Storage sont très probablement ouvertes en écriture. Aucun storage.rules dans le repo — à relever dans la console.
✅ Tranché le 13/09 : URL signée + ingestion. L'API délivre une URL V4 signée (15 min, un objet, taille bornée), le navigateur envoie directement chez Google dans incoming/, puis POST /ingest : un ResourceIngestionService unique — le même pour le Studio et le TTS — mesure la taille réelle, applique quota et plafond, post-traite les images (2 à la fois au plus) et range sous pictures/. Les vidéos, 360 et GLB sont copiés côté Google, jamais lus par le VPS. Les écritures clientes sont ensuite fermées dans les règles.
Infra : CORS du bucket pour le PUT, règle de cycle de vie à 1 jour sur incoming/, et fermeture des règles après le déploiement du nouveau manager-app. À caser dans le même lot : suppression de la route upload morte et d'ImageHelper, Firebase:StorageBucket renseigné, et LB (luxembourgeois).
Constat du 13/09. Le seul code de watermark vivait dans ResourceController.Upload (base64, System.Drawing) : il écrivait le texte « fortsaintheribert.be » en dur, en Arial. manager-app n'en applique aucun. Depuis que l'upload passe du navigateur à Firebase, aucune image n'est filigranée, même sur une instance où Instance.IsImageWatermark est activé.
Le lot 0 du Studio supprime ce code mort et ne réimplémente rien : pas de régression par rapport à la prod actuelle, mais le drapeau ment.
À trancher avant de le refaire, dans le ResourceIngestionService du lot 0 (le seul endroit qui voit passer toutes les images) :
aspnet:8.0 Linux — via SixLabors.ImageSharp.Drawing ;Image360, comme la compression.Distinct de la gravure « générée par IA » du Studio (Instance.IsAiWatermarkBurned, décision 6), qui réutilisera le même chemin.
🛠️ Lot 7 en cours (15/09). Entité Persona, écran Personnages, TtsVoice en base, migration des colonnes Instance.Guide* faits. Figures des 12 archétypes générées et embarquées. Reste : l'écoute des voix (échantillons audio à produire, pas de clé Gemini en local), puis le lot 8 (narration attribuée).
Trois plans décrivaient le même objet sans se croiser (relevé le 01-02/09) : le canon visuel (studio-plan.md), les 3 frames de lipsync (talking-head-plan.md) et PersonaConfig { WakewordId, GuideName, PersonaPrompt, VoiceName } (tts-pregenerated-plan.md). Une entité Persona les remplace : identité, visage (canon en jeu de vues), voix + intonation, parole.
⛔ Le talking head est abandonné, sa dépendance était déjà cassée. talking-head-plan.md:11 anime ses frames « selon les timestamps retournés par Google Cloud TTS » (enable_time_pointing) — or le code livré tourne sur Gemini TTS (GeminiTtsEngine, gemini-2.5-flash-preview-tts) : aucune source de timestamps. Remplacé par le portrait canon statique à côté du lecteur audio, puis une boucle vidéo 6-8 s si du mouvement est voulu. En-tête d'abandon posé sur le fichier.
⚠️ « Le visiteur choisit entre Viva et Marco » était un choix de voix déguisé en choix de guide. Le besoin réel est celui du Bastogne War Museum : 3-4 personnages qui narrent chacun leurs stations, avec leur voix et leur intonation. Le conservateur assigne (NarratorPersonaId sur GuidedStep/GeoPoint/SectionArticle), le visiteur ne choisit rien. Bonne nouvelle : ça divise le stockage TTS par deux au lieu de le multiplier — chaque contenu n'existe plus qu'en une voix.
Le wakeword devient une exigence du canal, pas une propriété du personnage. Il corrigeait un bug latent : GuideName est du texte libre sans lien avec le modèle OpenWakeWord, donc le visiteur dirait « Marco » à quelqu'un qui se présente comme « Léon ». Nouvelle règle : nom libre par défaut, contraint à un modèle disponible seulement si un canal mains-libres est actif (lunettes, casque VR). Mobile/web/kiosk : push-to-talk.
Deux champs à ajouter : Persona.VoicePrompt (l'intonation — aujourd'hui la constante de build kGeminiTtsPrompt, visitapp constants.dart:27) et une table TtsVoice à la place des deux constantes : deux voix ne suffisent pas à quatre narrateurs. Migration : les 4 colonnes Instance.Guide* deviennent une ligne Persona.
🔄 Révisé le 02/09 : le Kind Guide/Narrateur est abandonné. Un guide n'est rien d'autre qu'un personnage dont la facette parole est remplie. Donc un seul objet, et ce qu'il sait faire découle des facettes renseignées : VoiceId → il narre, SystemPrompt → il peut être guide, WakewordId → on l'appelle à la voix. Quatre combinaisons valides, y compris visage seul — une silhouette de décor qui revient huit fois a besoin d'un canon, pas d'une voix.
Un guide par visite est possible : Configuration.GuidePersonaId ?? Instance.GuidePersonaId. Donc le gouverneur peut narrer les étapes 1, 3, 5 et répondre aux questions sur ce parcours, en personnage. Assignation : le défaut se pose sur tout conteneur, la surcharge sur toute feuille — narrateur sur Instance → Configuration → GuidedPath/SectionMap, surchargeable sur GuidedStep, GeoPoint, SectionArticle.
✅ Le mains-libres n'est pas la limite que je croyais (vérifié dans le code le 02/09) : NativeWakeWordEngine fait tourner N modèles en parallèle et l'événement dit lequel a déclenché (detected:hey_marco). Quatre tournent déjà, en dur (voice_controller.dart:105), sans lien avec le CMS. Cinq modèles sont embarqués : hey_viva, hey_marco, hey_alba, hey_vasco, hey_visit. La vraie contrainte est que le catalogue de noms est fini et embarqué au build — un autre prénom demande un entraînement et une republication de l'app.
🐛 Bug relevé au passage — le chemin mains-libres Android est cassé. Le moteur passe le nom du modèle dans onDetectedWithCommand, que VoiceOrchestrator._onWakeWordWithCommand (voice_orchestrator.dart:132) interprète comme la question du visiteur et envoie à _dispatch(). Le visiteur dit « Hey Marco », l'assistant répond à la question « hey_marco » et n'ouvre jamais le cycle d'écoute. À corriger indépendamment du Studio.
⚠️ VisitorQuestion a ConfigurationId, AppType, IsVoice, ThemeId — mais pas de PersonaId. Sans lui, impossible de ventiler les stats par personnage, donc de voir qu'un personnage répond mal.
Une photo d'un bâtiment du Fourneau → sa version d'époque → un personnage en boucle de 8 s. Le but n'est pas de produire un asset, c'est de répondre à la seule question dont dépend tout le reste : est-ce que ça a l'air crédible, ou est-ce que ça décroche ? En patrimoine la crédibilité est la valeur — si le rendu est inquiétant, la fonctionnalité n'existe pas, quels que soient le prix et la facilité.
Le principe à valider : le text-to-video pur est écarté (il invente la géométrie du lieu). Le lieu réel reste la base, l'IA n'ajoute que le mouvement — c'est « Point de départ » du Studio transposé à la vidéo, donc aucun mécanisme nouveau en base : Kind = Video et sourceFidelity existent déjà, c'est un gabarit de plus.
Respecter les contraintes pendant le test, sinon il ne prouve rien : 6-10 s en boucle, une seule personne, mouvements simples, caméra calme, plan large ou moyen, jamais de gros plan sur les mains ou le visage.
⚠️ Deux choses déjà tranchées, à ne pas rejouer. Le motion transfer part en prestation Unov, pas dans le produit : il demande un tournage et un consentement écrit, ce qui échoue par construction le critère du §0 (« un conservateur produit sans intervention »). Et le cadrage de vente est resserré à « là où il ne reste rien » — bâtiment disparu, métier éteint, site réduit à ses fondations : partout où des archives existent, une vraie photo d'époque bat une boucle générée.
⛔ Ne bloque rien et n'est bloqué par rien — c'est justement l'intérêt de le faire tôt. Mais la fonctionnalité, elle, reste derrière le prérequis dur du Studio : IResourceBlobService n'expose toujours que IsConfigured et DeleteAsync, le backend ne sait pas écrire dans le bucket.
Stack tranchée le 31/08 : Unity 6 LTS + OpenXR + Meta XR feature group. Le contenu se récupère par GET /api/configuration/{id}/export — un seul appel qui renvoie configuration, sections et ressources, donc aucun DTO C# à réécrire endpoint par endpoint. Unity n'est pas une nécessité technique : le mode kiosk vient de la gestion d'appareil, pas du moteur. Unity gagne sur l'exploitation sans surveillance — cache offline multi-Go, décodage 8K, et un build figé là où le navigateur du casque s'auto-mettrait à jour et pourrait casser la borne un mardi matin. Retenu : WebXR pour la maquette, Unity pour la borne, à rouvrir si l'usage devient « casque 5 min avec un agent à côté ».
✅ Le POC est écrit en entier — E0 à E10, entre le 11 et le 12/09. Unity 6000.0.83f1, projet URP dans vr-app/, APK sideloadé sur un Quest 2 (Horizon OS v207). Mesuré sur casque : un décor glTF de 50 Mo charge en 1,4 s et tient 72 FPS, GPU au maximum.
| Item | Ce qui est écrit |
|---|---|
| E2-E3 | Appairage par code PIN (PairingService) et lecture de l'export. ⚠️ Un 404 sur POST /api/device ne veut pas dire « introuvable » : il n'y a pas d'ApplicationInstance VR sur l'instance. |
| E4 | Cache d'abord : l'app démarre sur le disque, se rafraîchit en fond, et l'échec du rafraîchissement ne se voit pas. Écriture atomique — une borne se débranche le soir. C'est l'argument n°1 d'Unity contre le web. |
| E5 | Menu flottant en arc à 2,2 m, qui ne suit pas la tête. Trois moyens de viser : manette (gâchette), main (pincement), tête (1,2 s, contre le « Midas touch »). ⚠️ « À la tête », pas « aux yeux » — le Quest 2 n'a pas d'eye tracking. ⚠️ La dépendance E5 → E1 du plan était fausse : OVRInput et OVRHand viennent du Core SDK, donc E1 sort du chemin critique. |
| E6 | Image et vidéo 360° en skybox. Le retour au menu ne reste pas planté dans le décor : 4 s, puis il s'efface et revient quand on baisse les yeux — toute la valeur d'une 360 est d'y être. ⚠️ Deux pièges invisibles dans l'éditeur : Skybox/Panoramic doit être dans Always Included Shaders (sinon ciel magenta sur le casque seul), et une équirectangulaire décodée pèse 134 Mo — compressée en ASTC et libérée à la sortie, sinon l'app meurt après quelques 360. |
| E8 | Slider, Map, Parcours et Event, dans une même vue paginée. ⚠️ Deux écarts assumés : la Map est une liste de POI, pas la maquette 3D promise (elle demande SectionModel3D) ; le Parcours est rendu comme une Map, pour la même raison. |
| E9 | Télémétrie « tire et oublie ». ⚠️ Route /api/stats/event, et appType y part en chaîne parsée par nom : une faute de frappe retombe sur Mobile sans erreur. |
| E10 | Fin de visite à la repose du casque ou après 90 s, menu recentré devant le visiteur suivant, nouvelle session de stats. ⚠️ Le verrouillage de l'app sur le casque n'est pas du code : c'est le mode appareil de Meta. |
⚠️ Rien de E2 à E10 n'a jamais tourné — ni contre un vrai serveur, ni sur le casque. Le code compile, c'est tout ce qu'on sait. Plan de test §25, 9 blocs, écrit pour ça ; son §25.7 (déclarer une 360 dans le manager) se joue sans casque.
⚠️ Le risque est produit, pas technique, et il est intact. Le CMS est 2D : un Article sur un panneau flottant est moins bon qu'une tablette. La valeur du canal vient du contenu 360°, que peu de clients possèdent. Go/no-go conditionné à un client pilote disposant déjà de vidéos 360° — rien de ce qui a été codé ne répond à cette question-là.
🟡 XR-5, la supervision de flotte : l'essentiel est écrit (12/09). Un battement HTTP toutes les 3 min remplit enfin les colonnes batterie / version / dernier vu de l'onglet XR, qui affichaient « — » faute d'alimentation — Update n'écrivait ni AppVersion ni LastSeen. Pas de MQTT : à 1-3 casques il coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour pousser vers le casque, pas pour remonter. Reste : le client MQTT le jour où l'on voudra recharger une configuration à distance, et une alerte quand un casque ne donne plus signe.
🟡 E7, la maquette 3D, est écrit aussi (12/09). SectionModel3D est un vrai type de section — une maquette n'a ni fond cartographique, ni zoom, ni coordonnées terrestres — mais il réutilise le GeoPoint, qui portait déjà deux rattachements : les points gardent titre, description, audio et multilingue. ✅ L'« éditeur de placement 3D », annoncé comme le seul morceau non trivial du lot, n'était pas à écrire : vr-app/viewer sait déjà le faire et son protocole postMessage existait déjà, pensé pour ce cas. Le manager le monte en iframe, comme il le fait déjà trois fois ailleurs. Côté casque, rien de dessiné non plus : on construit un manifeste et on le passe au moteur de la piste S. ⚠️ Le viewer n'a jamais tourné, et doit être servi sous le même domaine que le manager.
Reste au lot : E11 (distribution : Horizon Store ou canal privé, à revérifier avant toute offre). ⚠️ MDM rétrogradé le 31/08 : inutile à 1-3 casques, sideload + mode appareil suffisent.
Deux pages, pas une (arbitré le 10/09) : /lunettes-connectees pour l'add-on Ray-Ban Meta et /casque-vr pour le canal immersif Meta Quest. Les deux publics et les deux modèles économiques diffèrent — un add-on mensuel d'un côté, un palier Immersif de l'autre — et les mettre sur une seule page brouillerait les deux.
Aujourd'hui, la landing n'en dit qu'une ligne : « Meta Quest et lunettes connectées » dans le 4e mode de déploiement, avec un badge « Bientôt ». C'est honnête et c'est suffisant tant qu'il n'y a rien à montrer.
⏱️ Condition d'écriture : un module démontrable. Publier une page produit sur une fonctionnalité qu'on ne peut pas faire tourner en rendez-vous se retourne contre nous — c'est le même raisonnement que pour la vague 2 des posts LinkedIn. Le reste du contenu (positionnement, prix, arguments) est déjà écrit dans les deux docs de la colonne source.
myinfomate-landing/audit-seo-geo.md action 19 · DOCS/v2/vr-quest-unity-plan.md · DOCS/rayban-meta-integration.mdClé de signature JWT, clés API Gemini et OpenWeather, connection strings prod, mot de passe MQTT, token Telegram. Le dossier RELEASE/ republie d'anciens secrets.
Déplacée depuis « Urgent » le 11/08, à la demande : les 6 repos sont privés, sur un 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 bouge pas, la fenêtre d'urgence oui.
⚠️ Ce qui ne se négocie pas : elle doit précéder la création de l'environnement prod (étape 16), sinon la prod naît avec les clés de l'historique et il faut rotationner deux fois. Et comme le test d'onboarding exige app.myinfomate.be déployé, ça veut dire avant la fin du lot de tests, pas au tout dernier moment. Elle remonte en « Urgent » immédiatement si un repo passe public, gagne un collaborateur externe, ou est cloné par un CI tiers.
dryRun=true n'écrit rien. 🔨 Joué le 11/08 sur l'export du 1ᵉʳ avril, et automatisé en test (MigrationDryRunTests, qui se saute si aucun Mongo n'écoute — la suite reste verte sans dépendance).
Il a trouvé du premier coup ce qu'aucune lecture de code n'avait vu : 327 sections dans Mongo, 307 migrées, et Erreurs : 0. Les 20 manquantes référencent deux configurations supprimées dans Mongo — 19 articles et un slider, du contenu MDLF bien réel (« Senteur : fraises », « Pupitre vigne », « Ruche 7 »). Ce n'est pas un défaut de la migration : les sections sont collectées configuration par configuration, donc les orphelines n'étaient jamais énumérées. Le défaut était le silence — elles partent maintenant dans Skipped, et le test affirme migrées + signalées = Mongo.
⚠️ Décision produit : recréer les 2 configurations dans Mongo avant la bascule pour récupérer ces 20 sections, ou acter leur perte. À trancher avec MDLF.
⚠️ À rejouer sur un dump frais : l'export a 4 mois et la prod tourne encore sur Mongo. Et MigrationController lit un Mongo vivant, pas ces fichiers — recette d'import dans v1-plan.
Deployment/Dockerfile.postgres (digest épinglé), compose prod, Traefik, port 5432 fermé. Puis dotnet ef database update sur la base vide et vérifier que postgis et vector répondent. Ne pas créer la prod avec les clés encore committées.
Scripts écrits et testés le 07/09 dans manager-service/ManagerService/Deployment/backup/ : dump -Fc avec vérification de relecture et rotation, test de restauration comparant les comptages table par table (25 tables identiques), extraction par instance, contrôle de fraîcheur, units systemd, et une procédure de restauration écrite (RESTORE.md).
La destination existe depuis le 07/09 : BACKUP_DEST=gcs:unov-myinfomate-backups/pg, service account incapable de supprimer (vérifié par un 403). Voir la carte close « Trancher la destination des sauvegardes hors site ».
Ce qui reste : rclone n'est ni installé ni configuré sur le VPS, et il n'y a de toute façon pas encore de base Postgres en prod à sauvegarder. Cette carte est donc une étape à l'intérieur de « Créer l'environnement prod Postgres », pas un chantier parallèle — une vingtaine de minutes le jour venu, plus l'acheminement de la clé du service account en chmod 600.
Deux constats du test : les embeddings exclus font passer le dump de 12 Mo à 1,7 Mo, et le « collation version mismatch » du README s'est reproduit — il bloque createdb, donc toute restauration.
notify.sh sur un canal réelTrois surveillances sont en place — échec du script (OnFailure=), sauvegarde plus vieille que 48 h, test de restauration mensuel — et toutes appellent backup/notify.sh, qui ne fait aujourd'hui qu'un logger.
Le mode de panne réel n'est pas « le script plante » : c'est « le script ne tourne plus depuis trois semaines et personne ne l'a vu ». C'est précisément celui qu'un OnFailure n'attrape pas et que le contrôle de fraîcheur attrape — à condition que l'alerte sorte de la machine. Renseigner BACKUP_ALERT_WEBHOOK, ou brancher le canal mail du service.
Le paramètre instanceId existe précisément pour ça. Commencer par la plus petite instance : si quelque chose casse, ça casse sur le plus petit périmètre possible.
Firebase:StorageBucket, puis lancer le backfillDeux gestes que C2 et C3 laissent à la mise en prod. 1) La clé Firebase:StorageBucket (<project-id>.appspot.com) est vide par défaut : sans elle, Delete ne supprime aucun blob — il ne casse rien, il ne nettoie pas. C'est aussi le verrou de la carte « retirer la suppression de blob de manager-app ». 2) Lancer le backfill : POST /api/Resource/backfill-storage?dryRun=true, relire Orphans et Unsized, puis dryRun=false. Sans lui le quota de C3 porte sur des SizeBytes à 0 pour tout l'existant, donc il laisse tout passer (L10).
⚠️ Piège de build repéré le 12/08 : dotnet restore échoue en 401 si la source NuGet git.dev-espaces-naturels.lu est déclarée dans l'image — elle n'a rien à voir avec ce projet. Google.Cloud.Storage.V1 étant le premier paquet ajouté depuis longtemps, le prochain build d'image la rencontrera.
Login manager-app, une app visiteur avec sa clé API, un parcours, une carte, un PDF, un quiz avec ses questions. Les défauts « silencieux » ne se voient qu'ici — pas dans le rapport de migration.
STATUS.md §1quinquies — étape 20Décidé le 12/08, remplace la bascule DNS. Un second serveur porte la nouvelle prod Postgres, les nouvelles versions des apps pointent vers lui, et l'ancienne prod Mongo continue de servir les apps déjà installées. Aucun DNS ne bouge, pas de fenêtre de maintenance, et un retour arrière qui consiste à ne rien faire.
C'est la seule réponse au problème que L19 pose : on ne force pas la mise à jour du téléphone d'un visiteur.
Ce qui rend la coexistence saine — une seule source d'écriture : dès que le back-office pointe sur la nouvelle prod, l'ancienne est figée de fait. Les vieilles apps voient un contenu gelé au jour J, ce qui est tenable quelques semaines, et les deux bases ne divergent jamais.
⚠️ Ce qui ne marche pas : « recopier de la nouvelle prod vers l'ancienne ». Il n'existe pas de chemin Postgres → Mongo ; MigrationController va dans un seul sens et écrire l'inverse coûterait autant, pour alimenter une base qu'on éteint. L'ancienne prod se fige, elle ne se synchronise pas.
À tenir : une date d'extinction décidée d'avance (sinon l'ancienne prod survit trois ans), le coût d'un second serveur OVH le temps de la transition, et un plan de rollback réécrit — il ne s'agit plus de restaurer un dump mais de republier l'app qui pointe vers l'ancienne adresse, ce qui déplace le risque sur les délais de validation des stores.
v1-plan.md lot I — stratégie de coexistenceSoixante chantiers clos entre le 5 et le 13 août 2026. Les lots F et J sont clos, et il ne reste aucun bug ouvert.
_toLangCode dédoublonné
⚠️ La purge d'AuditLog avait été oubliée à la première passe du lot J, alors que le plan l'y assignait explicitement. AuditLog porte un UserId, et les valeurs avant/après d'une modification de User contiennent e-mail, prénom et nom — conservés sans limite, quand VisitEvent purge à 13 mois et VisitorQuestion à 90 jours. 12 mois, uniforme ; pas 13, les 13 mois des stats servent à comparer une saison à la précédente et les recopier serait du mimétisme — un test fige l'écart. Inerte tant qu'Audit:RetentionDays n'est pas défini : pas de pg_dump, pas de suppression.
⚠️ ExecuteDeleteAsync n'est pas traduisible par le provider InMemory : la suppression se teste contre un vrai Postgres. L'écrire en chargeant les lignes puis RemoveRange l'aurait rendue testable en mémoire au prix de charger un an de journal — dégrader le code de production pour satisfaire un provider de test.
Mention visiteurs dans visitapp-web, panneau dépliable dans l'en-tête de l'assistant, même texte que l'app mobile. _toLangCode était copié trois fois et la décision « FR/NL/EN/DE seulement » n'était écrite que dans une copie : les deux autres ressemblaient à un oubli qu'on aurait « corrigé » en ajoutant des langues, produisant un support partiel silencieux. Une seule copie désormais, une seule décision.
(instance, mois, thème, compteur) : c'est elle qui tient la promesse du §8.4 des CGU. Sans elle le regroupement vit dans la ligne VisitorQuestion, donc la purge du 90e jour l'emporte avec la question et le client perd tout au 91e. Elle ne porte que des compteurs — aucune donnée personnelle, ce qui est précisément ce qui l'autorise à survivre. ⚠️ Insights lit désormais les thèmes dans cette table, pas dans les questions de la fenêtre.
⚠️ Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA. Des libellés régénérés à chaque passage donneraient « Horaires » en janvier et « Questions d'horaires » en février : deux lignes distinctes, et une courbe qui ne veut rien dire — alors que la table existe pour porter cet historique. Ajouter un thème reste possible, en renommer un coupe l'historique en deux.
⚠️ Le plafond de 500 par passage ne perd rien, il retarde : les plus anciennes d'abord, un passage par jour, et le job tourne à 2 h quand la purge est à 3 h 30. Un avertissement part si le retard dépasse un passage. Les jetons ne sont pas décomptés du quota client — il n'a pas demandé ces appels. Un lot en échec n'est pas marqué « Autre » pour s'en débarrasser : ce serait une perte définitive maquillée en résultat.
Interrupteur de collecte avec UI dans l'écran Guide IA — sans elle le client ne pouvait pas exercer le refus dont il est responsable. Mention aux visiteurs accessible depuis l'assistant, là où la collecte a lieu. ⚠️ FR/NL/EN seulement, repli anglais : traduire une mention de protection des données sans relecture serait pire que la servir en anglais. dotnet test 226.
print("NOT SUCCESSS"), et le téléchargement rend un échec si la liste n'est pas vide. Nouvelle clé downloadIncomplete dans les 10 langues — sans quoi getFromLocale aurait rendu une chaîne vide, le piège déjà rencontré avec event.live.
⚠️ Trouvé en câblant l'écran, et c'est pire que le bug d'origine : sans état d'échec, une visite incomplète restait sur « téléchargement en cours » indéfiniment. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc il n'atteignait jamais le total et l'écran ne concluait jamais — le visiteur attendait devant une barre figée. ✅ Relancer ne re-télécharge que ce qui manque.
StatisticsService.track porte isVoice, qui pose "voice": true dans VisitEvent.Metadata — colonne existante, donc aucune migration et le gel du lot B tient. Le serveur expose « sessions ayant utilisé le vocal » et statistics_screen lit cet agrégat au lieu d'appTypeDistribution['Voice'], qui ne se serait jamais rempli.
⚠️ Le sectionView vocal part après la synthèse, pas avant : un déclenchement dont le TTS échoue n'a rien fait entendre, le compter serait faux. ⛔ Aucun événement par question posée — elles sont déjà dans VisitorQuestion. ⚠️ Le test InMemory ne prouve rien ici, le provider évaluant le filtre côté client : un test Postgres a été ajouté à côté, il se saute sans démon Docker.
⛔ L9 était un malentendu de doc, pas du travail. La « cible Assistant / Persona » du Mode preview est exactement ce que le §5 du Guide IA a livré le 11/08. Les trois autres cibles (iframes mobile/web/kiosk, viewports, preview-token) sont hors V1. ⚠️ Mais l'aperçu polluait les stats du client : chaque essai de personnalité par le gestionnaire était journalisé comme une question de visiteur. Corrigé — et le drapeau d'hier, IsAutoTriggered, est renommé IsVisitorQuestion (défaut true) parce que son vrai sens couvrait déjà les deux cas. dotnet test 218.
Arrêté et créé le 07/09 : projet GCP myinfomate-backups, bucket gs://unov-myinfomate-backups en EU multi-région, versioning, accès public interdit, lifecycle 400 jours sous pg/. Service account backup-writer en objectCreator + objectViewer — incapable de supprimer, vérifié par un 403.
Les médias pèsent 1,43 Go (mesuré, pas estimé) : ils tiennent dans le même bucket sous media/, donc pas d'OVH Object Storage. La disposition croisée envisagée le matin même est abandonnée — elle se justifiait sur un volume supposé de 45 Go, qui venait en réalité du stockage Google One personnel.
conversationId
Le chat écrit, le vocal et le proactif partagent désormais une seule conversation, portée par VisitAppContext.assistant. Les trois AssistantService séparés ont disparu ; le maxHistory: 6 du vocal s'aligne sur 10, deux surfaces qui partagent une conversation ne pouvant pas la tronquer différemment selon le point d'entrée. La concision vocale vient d'isVoice, qui change le prompt côté serveur.
⚠️ Le vrai obstacle n'était pas l'affichage mais la forme du chat. AssistantChatSheet gardait ses messages en List<Widget> — des bulles déjà construites. On ne rejoue pas une conversation à partir de widgets, et un tour vocal survenu pendant que la feuille était fermée n'aurait jamais pu y entrer. Le service porte maintenant les tours en données et notifie ; la feuille rend depuis eux et se met à jour en direct.
⚠️ Deux listes, délibérément : celle envoyée au modèle et celle affichée ne coïncident pas. Un message d'erreur se montre sans repartir au guide — il n'a pas à s'excuser d'une panne au tour suivant ; le prompt d'un déclenchement proactif part au guide sans jamais s'afficher, seule sa réponse apparaît, marquée « À voix haute ». Sans ce marquage, le visiteur trouverait dans son chat des messages qu'il n'a jamais tapés.
✅ conversationId est enfin envoyé — et le champ manquait dans le client généré, ce qui explique que personne ne l'envoyait. Ajouté à la main. Il est renouvelé quand la conversation est vidée, sinon la visite entière d'un visiteur n'en formerait qu'une. 2 tests serveur fixent le lien entre deux tours et le repli Guid.NewGuid(), que rien ne couvrait. dotnet test 215, APK dev ✅, flutter build web ✅.
RecordVisitorQuestion écrivait Question = request.Message : chaque passage devant une œuvre aurait ajouté une fausse question de visiteur — une consigne que le système s'écrit à lui-même — dans l'écran fait pour montrer ce que les humains demandent. Et HasAnswer se déduisant des sources, un prompt d'accueil sans citation aurait gonflé le bloc ambre des questions sans réponse. Même famille que WeatherSyncService noyant le journal d'audit.
Tranché : ne rien journaliser, plutôt que marquer d'un drapeau. Une ligne VisitorQuestion sert au rapport de trous de contenu et aux thèmes du lot J ; un prompt machine n'alimente ni l'un ni l'autre, et le garder « marqué » obligerait chaque futur agrégat à penser à l'exclure — la classe de panne qu'on venait de fermer sur AuditedTypes. ⚠️ Mais les jetons restent comptés : ces tours coûtent de l'argent réel au quota, et un audioguide proactif peut en consommer beaucoup. 2 tests fixent la paire — pas de ligne, compteur incrémenté — parce que c'est la combinaison qui compte.
Trois repos : manager-service (dotnet test 213), manager-app (manager_api_new étendu à la main, flutter build web ✅ — le client est partagé, donc vérifié) et mymuseum-visitapp (APK dev ✅).
dev vert. ⚠️ La garde recommandée par le plan aurait coûté de l'argent : _trigger appelle le LLM d'abord et ne parle qu'ensuite via activeVoiceOrchestrator?.ttsEngine, dont le ?. avale le cas « aucun mode vocal actif ». Avec proactiveModeEnabled seul, un visiteur traversant une zone avec l'app en poche et le vocal éteint consommait des jetons Gemini facturés au client pour une phrase que personne n'entend. La garde retenue interroge l'orchestrateur : la vraie condition n'est pas « quel matériel » mais « y a-t-il quelqu'un pour écouter ».
⛔ Il n'y avait pas de troisième mode à inventer : VoiceMode.voiceOnly existe déjà et construit le même orchestrateur que le mode lunettes — l'audioguide sur téléphone était là. Le proactif est donc un sous-réglage, pas une tuile : l'opposer aux deux autres forcerait à choisir entre « je pose des questions » et « on me parle ». glassesEnabled est supprimé, ses trois usages étaient morts — dont une pastille « Lunettes / Déconnecté » que personne n'avait jamais vue.
M3 : meterZoneGPS devient le rayon par section, la constante ne servant plus que de défaut. ⚠️ Deux pièges de format vérifiés : currentSections porte des maps JSON brutes, pas des DTO, et latitude/longitude y sont des chaînes. ⚠️ Reste un bloquant avant d'activer le mode — voir la carte « Ce que demandent vos visiteurs » en Bugs ouverts.
Instance.GuideVoiceId, mais mymuseum-visitapp ne lisait jamais ce champ : il utilisait une constante de build, dont le défaut était Algieba — une voix que personne n'a jamais écoutée, héritée d'avant le sélecteur. 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 ; le repli passe à Sulafat (Viva).
⚠️ Et aucun APK ne parlait avec la voix du produit. GeminiTtsEngine n'est choisi que si GEMINI_API_KEY est injectée au build — elle ne l'était nulle part, donc tous les builds retombaient en silence sur la voix système d'Android. C'est le silence qui l'a rendu invisible des mois. Le repli reste le défaut, et c'est voulu : les tests de terrain ne consomment ni jetons ni argent. Il s'annonce maintenant dans les logs, constants.dart porte la commande, et launch.json a une configuration « Dev + voix Gemini ».
✅ Au passage, une ligne périmée de STATUS tombe : « TTS = ElevenLabs, basculer sur Gemini avant de vendre l'add-on » — c'est déjà fait, constants.dart dit « ElevenLabs retiré du pipeline (trop cher) ». Le risque de marge qui motivait cette ligne est écarté. impl/elevenlabs_tts_engine.dart est conservé comme option, à rouvrir seulement si un client juge la voix Gemini insuffisante.
wake_word_service.dart, glasses_qr_scanner_service.dart et glasses_tts_service.dart portaient les 4 erreurs Dart connues (kElevenLabsApiKey, kElevenLabsVoiceId) et l'APK se construisait quand même : personne ne les importait — un îlot hors du graphe de main.dart, même mécanique que les fichiers orphelins de manager_api_new. Supprimés le 13/08 : flutter analyze lib rend zéro erreur, une première pour ce repo. ⚠️ Piège d'homonymie : android/…/WakeWordService.kt porte le même nom et est bien vivant — service natif déclaré au manifeste, c'est lui qui fait tourner le wake word. Seul le Dart est parti.
⛔ Mais le §5bis en tirait « l'APK se construit sans le POC dedans », ce qui est faux. Ces deux fichiers sont les ancêtres de Services/Glasses/ ; le POC actuel, lui, est bien dans l'APK. Conséquence inverse de celle qui était écrite : « démontrable » ne suppose pas de remettre les constantes ElevenLabs — le chemin vivant choisit GeminiTtsEngine et ne les touche jamais. La bascule TTS → Gemini réclamée avant de vendre l'add-on est déjà faite dans le code.
⚠️ Et ça rétrécit le chantier du miroir vocal : j'avais compté cinq AssistantService distincts, donc cinq historiques. Deux sont dans ce code mort. Il y en a trois de vivants — le chat, le vocal, le proactif.
src/data/segments.ts, alors que les cartes de tarifs disent « Premium » : un prospect qui lisait la grille puis la FAQ voyait deux noms pour la même offre. Les formes composées des langues germaniques suivent (Bundle-abonnementen → Premium-abonnementen, Bundle-Pläne → Premium-Pläne). Vérifié : plus aucun « Bundle » dans myinfomate-landing/src, et les 41 occurrences étaient toutes de la prose — aucun identifiant touché. npm run build vert.
if (isTrialActive) enveloppait le seul bouton, donc un client payant arrivait sur un écran sans action. C'est désormais Checkout pendant l'essai, portail après ; les deux ne coexistent jamais. ⚠️ Le 409 a son propre message : les 4 instances de la bascule viennent de Mongo et n'ont pas de StripeCustomerId — « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. 4 clés i18n FR/EN/NL.
Diviseur de questions : guide_ia_screen lit aiTokensPerQuestion du serveur au lieu du /1000 codé en dur, par appel http direct — le motif déjà établi dans cet écran pour knowledge et insights. ⚠️ Si l'appel échoue, la ligne « ~N questions » disparaît plutôt que d'afficher un repli : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux — ce que le /1000 faisait depuis le début, à un facteur 10 près. flutter analyze lib sans erreur, flutter build web ✅.
StripeService.CreateBillingPortalSessionAsync + POST /api/onboarding/billing-portal, calqué sur checkout-session. ⚠️ Une garde absente de l'énoncé : StripeCustomerId peut être nul. CreateCustomerAsync n'est appelée que par l'inscription self-service, donc les 4 instances de la bascule, venues de Mongo, n'ont jamais vu Stripe — sans la garde, elles partaient chercher le portail d'un client inexistant et récupéraient une erreur d'API Stripe illisible côté front. C'est un 409, distinct du 404 d'instance inconnue.
Ratio jetons → questions : InstanceQuotaDTO.aiTokensPerQuestion est désormais mesuré sur les VisitorQuestion.TokensUsed de l'instance, avec un seuil de 20 questions avant de faire foi — une seule réponse citant un long article doublerait la moyenne, et le crédit restant afficherait moitié moins d'un rafraîchissement à l'autre. ⚠️ Le /1000 de manager-app était faux d'un facteur 10, et c'est un chiffre montré au client : la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit 10 000 par question. Le gestionnaire lisait un crédit dix fois trop généreux. Le repli serveur est calé sur 10 000. Reste à brancher le diviseur dans manager-app.
⛔ Trois points du lot F étaient déjà faits et n'attendaient qu'une vérification : le rate limiting (livré, pas seulement décidé), l'endpoint de mise à jour d'ApplicationInstance (CRUD complet existant — ce qui manque est l'écran SuperAdmin, déjà parqué en V2) et les quotas seed (alignés par AlignSubscriptionPlansWithPricing). 6 tests ajoutés, dotnet test 197 passés / 14 sautés / 0 échec.
flutter build web vert. Un seul helper appelé par les deux chemins d'upload de resources_screen — le motif exact qui a fait diverger Create et Upload côté serveur, sur StoragePath en C1 puis sur le pré-vol de quota en C3. ⚠️ La compression se fait avant resourceCreate, pas avant l'upload : le quota de C3 porte sur sizeBytes à la création, donc compresser après aurait fait décider le serveur sur la taille d'origine — et un quota qui compte 12× trop se remplit 12× trop vite.
⚠️ Écart assumé à l'énoncé : un PNG à canal alpha reste un PNG. Le passer en JPEG remplirait la transparence de noir et abîmerait logos et filigranes ; seul l'encodage diffère, le redimensionnement s'applique aux deux, et le type MIME suit. Si le ré-encodage grossit l'original — ce qui arrive sur une petite image déjà bien encodée — l'original est conservé. ⚠️ Défaut préexistant trouvé au passage : le second chemin d'upload ne renseignait sizeBytes ni avant ni après. Tout ce qui est passé par là compte 0 octet au quota sur l'existant.
section_page_detail, qui enveloppe les treize types et portait déjà les crochets des statistiques — dans les écrans, il aurait été écrit treize fois.
Bénéfice de bord qui n'en est pas un : le retour passe par le même dispose, donc il émet un sectionLeave avec sa durée réelle. Sans lui, une consultation abandonnée restait ouverte jusqu'au visiteur suivant et gonflait la durée mesurée d'autant — K8 répare une statistique en plus d'un écran. Listener plutôt que GestureDetector pour réarmer : il observe les pointeurs sans concurrencer les gestes des écrans en dessous. ⚠️ Non vérifié : le comportement après 5 minutes réelles, et le fait que l'accueil soit bien la première route de la pile.
flutter build apk --debug vert, APK produit. Seul SectionParcours reste dehors, et définitivement — un parcours guidé fait marcher le visiteur. Les deux écrans suivent la maquette paysage : 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 et non en showModalBottomSheet.
⚠️ La barre de pied de la maquette n'appartenait pas aux écrans : section_page_detail dessine déjà le bouton retour, avec les clés back/menu selon isFromMenu. Les dessiner aurait donné deux boutons. ⚠️ Un seul constructeur de marqueurs au lieu des quatre de mymuseum : MapAnnotationDTO et MapAnnotation ont des champs rigoureusement identiques mais sont deux types Dart distincts. Normalisé 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 contents avant l'API : ContentDTO porte son resource complet, donc l'audio est trouvé sans appel réseau quand il est embarqué — donc hors ligne aussi. Mymuseum appelle l'API systématiquement en ligne. ⚠️ Clé i18n event.live ajoutée aux 10 langues — sans les 10, getFromLocale renvoie "" et la pastille rendrait une boîte vide. 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 — un build vert ne dit rien d'une mise en page, c'est la leçon du §1bis appliquée au design. Et aucun SectionEvent n'existe en base, le type étant né avec Postgres v3 : c'est le §19.13 cas E qui en créera un.
mymuseum-visitapp se construit, et le lot n'était pas ce qu'on croyait
La prémisse était périmée. Trois documents annonçaient « flutter build apk échoue côté Gradle/NDK », revérifié le 11/08. Les trois flavors (dev, mdlf, fortsaintheribert) construisent en exit 0, APK à l'appui. 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 ne restait pas la même migration à refaire ici. Le « réflexe du diff » noté la veille donnait la réponse en deux cat — appliqué, il l'a donnée.
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 ; aligne sur tablet-app, aucune version neuve introduite). Six builds au total — les trois flavors avant, les trois après — logs relus intégralement, codes de sortie lus hors redirection, APK datés vérifiés sur disque : les trois pièges de vérification du 12/08 sont couverts. Effet mesuré : l'APK passe 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. 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 — le plan parke déjà cette dette en V2.
⚠️ Une question ouverte pour K6 : le travail V1 récent de ce repo (D1, lot E, lot A) est committé sur Meta-Rayban-Test, pas sur master. C'est la branche de travail réelle, alors que le §5bis la décrit comme « un POC jamais mergé ». À trancher avant de publier. tablet-app est dans le même cas, sur AI-Assistant-test. 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.
POST /api/Resource/backfill-storage, SuperAdmin, dryRun à true par défaut — la migration se joue sur une base vide, ce backfill sur des lignes de production. La méthode annoncée au plan (« SizeBytes par listing du bucket Firebase ») était inapplicable : le serveur n'avait aucun client de stockage. Le sondage passe par HEAD, et le sondeur est extrait plutôt que recopié (ResourceSizeProbe, partagé avec la migration — même raisonnement que L5). ⚠️ L'extraction a bouché un trou que personne ne cherchait : l'original ne notait l'échec que dans son catch, or un HEAD sur un blob absent ne lève pas — il répond 404 sans Content-Length. Ces ressources arrivaient à 0 octet sans figurer dans le rapport. Le « 37 sur 45 » du plan n'étant pas vérifiable d'ici, le backfill rend son propre inventaire, Orphans et Unsized séparés — deux causes distinctes, jamais additionnées.
C3 : ⚠️ le pré-vol existait déjà à moitié et ce n'était écrit nulle part — Upload (multipart) contrôlait, Create (JSON) ne contrôlait rien, or c'est le chemin qu'emprunte manager-app. ⚠️ Et les deux lectures du quota divergeaient : Upload lisait le plan, GetQuota l'instance avec le plan en repli — une instance à quota surchargé (le mécanisme de l'add-on) affichait un chiffre et se faisait bloquer sur un autre. Fermé par StorageQuota. Delete supprime le blob avant la ligne et renvoie 502 en la conservant si le bucket échoue : une ressource encore listée se rattrape, un blob que plus aucune ligne ne désigne est facturé à l'aveugle. ✅ L'angle mort laissé par C1 était une fausse crainte : PathFor ne construit qu'un pictures/{instanceId}/{resourceId}, le type n'entre pas dans le chemin. Aucun secret nouveau : FirebaseAdmin était déjà là pour le push, Google.Cloud.Storage.V1 réutilise le même credential. dotnet test 163/163.
flutter build apk --debug vert. K1, K2 et K4 sont livrés et prouvés par un build, pas seulement par flutter analyze. L'outillage a demandé dix crans, pas les six annoncés en cours de journée : ce compte datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite — heap à 4096M, Jetifier coupé, forçage androidx.lifecycle retiré, Kotlin porté à 2.3.10. ⚠️ Trois de ces quatre derniers crans étaient de simples alignements sur mymuseum-visitapp, qui utilise le même plugin mapbox sans rencontrer aucun de ces problèmes : les causes ont été cherchées une par une alors qu'un diff des deux gradle.properties les donnait toutes d'un coup. Réflexe pour K5 : commencer par ce diff.resolutionStrategy forçait androidx.lifecycle à 2.4.0 sous le commentaire « To fix mapbox issue ». Il réglait un problème 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. Du contournement fossilisé, comme les trois blocs commentés qui ont fait mentir les diagnostics le même jour. K4 apporte au passage à la tablette le puzzle glissant, le bouton d'indice et un dimensionnement au ratio de l'image.
flutter analyze lib ne renvoie plus que les 6 PuzzleDTO de K4. Livré : TabletAppContext.currentAppConfigurationLink (copie du getter de mymuseum), lib/Helpers/geo.dart avec l'extension lat/lng, 67 accès roundedValue rebranchés, main_view et les 4 vues carte portées. La formule de clé par coordonnées de geo_point_filter, dupliquée à quatre endroits alors que les deux côtés de l'appariement doivent produire la même valeur, ne vit plus qu'à un seul.applicationInstanceDTO n'était assigné que si l'instance avait l'IA : il servait de drapeau d'assistant. Ce DTO portant désormais les AppConfigurationLink, le nouveau getter aurait renvoyé null sur toute instance sans IA — donc MDLF et le Fort — et les cinq réglages d'affichage seraient retombés sur leurs défauts, sans erreur ni log. Drapeau rendu explicite (isAssistantEnabled), en préservant le ET instance × canal. (2) ConfigurationDTO.isTablet ayant disparu, le filtre « configurations pour tablette » n'avait plus de source : il porte maintenant sur les liens du canal AppType.Tablet. À valider sur device — une configuration non rattachée au canal tablette rendra l'écran de sélection vide.
androidx.browser et core-ktx), suppression de android.bundle.enableUncompressedNativeLibs (retirée en AGP 8.1), jcenter() → mavenCentral() (arrêté depuis 2021), jvmargs 1536M → 4096M et enableJetifier true → false (Jetifier ne sert plus à rien depuis qu'AndroidX est partout, et c'est lui qui saturait la heap), et Kotlin 1.9.0 → 2.3.10 (les dépendances tirent kotlin-stdlib 2.3.10, métadonnées incompatibles avec un compilateur 2.0).
⚠️ Le cran le plus instructif : un resolutionStrategy forçait androidx.lifecycle en 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. Retiré, avec un commentaire pour qu'on ne le remette pas. ⚠️ Et un bloc buildscript entièrement commenté déclarait AGP 8.5.0/Kotlin 2.0.10 pendant que la vraie config vivait dans settings.gradle en 7.2.0/1.9.0 : il a fait conclure deux fois à une configuration qui n'était pas celle du build — supprimé. Troisième piège « bloc commenté » du projet.
Trois de ces crans sont des alignements sur mymuseum-visitapp, qui utilise le même plugin mapbox sans rencontrer aucun de ces problèmes. Réflexe à garder : comparer les deux gradle.properties et les deux build.gradle avant de chercher. Ce que ça débloque : compileSdkVersion 36, sans lequel plus aucune mise à jour n'est publiable sur le store. ⚠️ Le compte de « six crans » annoncé 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.
dotnet test 163 → 200, aucun test sauté, quatre commits, manager-service seul. 1) Le journal d'audit voyait tout sauf le contenu : AuditedTypes.Contains(entry.Entity.GetType()) exigeait l'égalité exacte, or Section est abstraite — 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. Tranché : normalisation côté serveur — EntityType porte Section, donc le filtre déjà présent dans l'écran se met à rendre des lignes sans toucher manager-app ; élargir le filtre front aurait coûté 13 entrées et 39 clés i18n et laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme. Le sous-type n'est pas perdu : le discriminateur TPH est sérialisé dans NewValues, un test le vérifie. 8 tests, dont un par réflexion sur les 13 sous-types — la liste d'origine n'obligeait personne à la suivre, c'est ce qui l'a laissée devenir fausse.
2) Plafond de 5 utilisateurs : CreateUser rend 422. 5 en dur (le faire varier par plan serait une colonne, donc une migration après le gel du lot B — dette V1 assumée) et SuperAdmin exempté, seule porte de service tant qu'aucun champ ne permet de relever la limite d'un client. Rien à retoucher côté front. 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, pour leur seul type d'événement, et les stats avancées ne sont plus calculées puis effacées pour les plans qui n'y ont pas droit. ⚠️ 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.
4) Le vector store est éprouvé à DEUX instances, sur un vrai Postgres — 14 tests Testcontainers sur l'image de Dockerfile.postgres, qui se sautent proprement sans démon Docker (186 passés / 14 sautés / 0 échec). pgvector 0.8.6 : le SET hnsw.iterative_scan que pose SearchAsync est accepté — sur une version antérieure, toute recherche échouait en production. À 30 contre 1, la recherche rend le bon nombre de résultats, tous de la bonne instance, aucune fuite. ⚠️ Deux résultats contraires à ce que le plan supposait : ce qui protège du post-filtrage n'est pas le parcours itératif mais l'index sur (InstanceId, ContentType) — le planificateur filtre d'abord et trie exactement, l'index HNSW n'est jamais touché (vérifié à 620 lignes et à 22 000) ; et cet index retiré, relaxed_order ne rattrape rien, le parcours s'épuisant après ~335 lignes sans atteindre l'instance minoritaire. 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. Outillage : Testcontainers épinglé en 3.10.0 (la 4.x parle l'API Docker 1.44, l'engine local plafonne à 1.43) et image construite par le CLI docker — le constructeur de Testcontainers ne sait pas parser le tag@sha256: du Dockerfile, digest qui protège la base d'un changement de glibc sous ses index et ne se retire pas pour un test.
⚠️ Et une régression du point 1, trouvée et 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. Le stockage est le moindre problème : ce bruit noie les modifications humaines que l'écran existe pour montrer. Correctif : 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 : estampillé à chaque SaveChanges, il figurait dans tous les diffs sans rien y apprendre — et le signal était déjà là, un test écrit le matin même devait l'écarter à la main pour rester lisible. AgendaSyncService vérifié au passage : il écrit des EventAgenda, non audités. ⚠️ Trouvé en cherchant à justifier un index sur Timestamp 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. L'index est retiré du backlog : pas de migration, gel du lot B intact. dotnet test 203/203.
manager-app seul. Écran « Activité » sur GET /api/Audit : filtres instance / type d'entité / utilisateur / plage de dates, pagination 50, clic sur une ligne → détail avant-après en table, entrée de menu réservée à role.value == 0 comme la policy de l'endpoint. Les ids sont résolus en noms, un id inconnu restant affiché tel quel — le journal doit rester lisible après la disparition de ce qu'il décrit. Compteur « X / 5 utilisateurs » et bouton d'ajout désactivé au plafond, SuperAdmin exclu : sa liste couvre toutes les instances, la compter contre un plafond par instance n'aurait aucun sens. manager_api_new n'a pas été touché — http.get direct au Bearer, comme le Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main. 40 clés i18n FR/EN/NL, flutter build web ✅. ⚠️ Trois pièges relevés en câblant : AuditController renvoie les entités brutes, pas un DTO ; invokeAPI ne lève pas sur code d'erreur et son résultat était ignoré, donc un e-mail déjà pris (409) ne produisait aucun message — corrigé ; et DropdownButtonFormField ne relit pas initialValue sur reconstruction, donc « Réinitialiser les filtres » vidait la requête sans vider l'affichage — remplacé par un DropdownButton piloté. Les deux dettes serveur qu'il ouvre sont sur leur propre carte, en Planifié.
BackfillInstanceAsync parcourait les sections d'une instance en boucle dans un seul job. Avec [AutomaticRetry(Attempts = 2)] posé sur la classe, un échec d'embedding à la 280ᵉ section faisait retenter le job entier par Hangfire — 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 qui avait causé le premier échec. Elle met désormais en file un job par section : l'unité de reprise devient la section, ReplaceAsync la rend idempotente, et le chemin de backfill cesse d'être un chemin à part — c'est celui de l'intercepteur, déjà éprouvé. Effet de bord utile : l'avancement se lit section par section dans /hangfire au lieu d'un job opaque. Complété par un backoff sur 429/503 dans GoogleEmbeddingService (3 tentatives, Retry-After s'il est fourni, exponentiel sinon), qui absorbe la rafale courte sans jamais remonter jusqu'à Hangfire. dotnet test 148/148. Découvert en préparant l'activation de l'IA sur MDLF et le Fort — deux instances Pro à doter à la main pour éprouver le post-filtrage HNSW sur 4 instances plutôt que 2.
switch qui collecte les ressources d'une configuration é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 — ni contenus d'articles, ni audios, ni icônes de carte, ni images de quiz. Remplacé par GetReferencedResourceIds(), qui existait déjà sur les 13 sous-types et n'attendait que d'être appelée. Côté import et côté client, la charge est enregistrée en une passe au lieu d'être redécouverte section par section : le serveur sait maintenant ce qu'il envoie, le client n'a plus à le deviner — et ça répare au passage la purge des fichiers obsolètes, pilotée par la même liste d'ids utilisés. 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, et c'est exactement ce qu'il ne faut plus pouvoir refaire. dotnet test 148/148. ⚠️ Le volet visiteur ne tient que sur l'analyse — l'APK ne compile pas — confirmation sur device au §21, carte « Visite hors ligne sur device » déjà en attente.
SectionEvent et SectionParcours sont nés avec Postgres v3, le default est un filet et non un trou, et le « scénario carnaval » qu'on croyait menacé est du contenu à créer, pas à migrer. (f) les trois booléens forcés à false : ces colonnes n'existent pas dans Mongo, false est le bon défaut — et le contrôle inverse a été fait, les cinq réglages qui existent vraiment dans l'export (IsDate, IsHour, IsSectionImageBackground, RoundedValue, ScreenPercentageSectionsMainPage) sont bien mappés sur AppConfigurationLink. (g) était tombé avec le rename du lot B. Trois cartes de la colonne « Bascule prod » se ferment sans une ligne de code.
QuizQuestions = new() jetait les questions : mesuré sur l'export, 5 sections quiz en portent 41, réponses comprises. Migrées, en MultipleChoice puisque le type de validation n'existait pas dans l'ancien modèle. Les agendas (aucun événement dans Mongo) et les parcours (inexistants avant) gardent leurs collections vides à raison. (d) était pire que décrit : Mongo n'a pas de champ Role, donc les 10 utilisateurs y avaient un accès complet, et le ContentEditor en dur les dégradait tous en silence — plus personne n'aurait pu gérer les utilisateurs de son instance. Passé à InstanceAdmin ; aucun SuperAdmin n'est créé par la migration, à poser à la main. (c) tout est généré ou dérivé, la source ne contenant que 4 champs. (e) StoragePath n'était pas écrit du tout : passe par le calculateur commun, et l'échec du HEAD, jusqu'ici avalé, remonte dans le rapport. (h) transaction unique, et surtout 500 au lieu de 200 OK sur échec fatal — le piège était qu'un script de bascule testant le code HTTP concluait au succès. dotnet test 143/143.
MigrationController revenait à l'écrire deux fois. Une seule migration EF porte les trois changements. SectionMap.MapResourceId → IconResourceId, ce qui fait tomber l'écart (g) de la bascule tout seul — et il fallait renommer la propriété de navigation MapResource avec, sinon la convention EF fabriquait un FK fantôme. Point vérifié plutôt que supposé : EF a produit un RenameColumn, pas un drop+add — les icônes déjà configurées survivent. SectionEvent.ParcoursIds supprimé (l'avertissement de perte de données d'EF ne porte que sur lui, c'est l'intention). Filigrane : Instance.IsImageWatermark remplace le instanceId == "633ee379…" en dur. Référence tenue : 130/130 tests avant comme après, build Debug et Release verts.
#if DEBUG » du rapport d'audit : dans AuthenticationController.Authenticate, un bloc écrasait l'email et le mot de passe reçus par un compte de test — toute compilation en Debug authentifiait n'importe quelle saisie en tant que test@email.be. Retiré. EnableSensitiveDataLogging (qui écrit les valeurs des paramètres dans les logs : hashes, clés API, données de visiteurs) passe sous #if DEBUG, l'idiome déjà employé dans ce même fichier pour le CORS et Hangfire — et le Dockerfile publiant en -c Release, c'est un vrai verrou, ce qu'un build Release a confirmé. Les autres #if DEBUG du repo sont légitimes.
GET /api/Instance/{id} renvoyait 403 à mymuseum-visitapp, qui l'appelle au démarrage pour la voix du guide : tout InstanceController porte [Authorize(SuperAdmin)] et GetDetail n'avait pas d'exception, contrairement à slug, byPin et app-key. Une clé API donne désormais accès à sa seule instance — clé croisée = 403 — et à une vue réduite : StripCommercialFields retire plan, quotas, usage IA, essai, TVA, facturation et le pinCode, qui ouvre l'appairage des tablettes. Le manager continue de tout voir.AuthorizationMiddleware authentifie avec les schémas de la policy du contrôleur — JwtBearer et ApiKey — et peuple HttpContext.User avant de court-circuiter sur [AllowAnonymous]. Une clé API produit donc un User authentifié auquel le handler pose le claim Viewer : la première version laissait passer tout le monde. Elle ne se voyait pas, parce que les champs sensibles de l'instance testée étaient naturellement nuls — c'est le test croisé sur une instance avec un pinCode qui l'a révélée. Le test porte maintenant sur le schéma d'authentification.StripeCustomerId et StripeSubscriptionId. C'est faux — ils sont sur l'entité mais pas exposés par ToDTO. Ce qui fuyait vraiment : plan, quotas, TVA, facturation, pinCode.version-3.1.3.
api.dart déclarait 139 part pour 153 fichiers. Les 14 restants portaient part of openapi.api; sans être listés par leur bibliothèque — en Dart, hors du graphe de compilation : l'analyzer les lisait, le compilateur ne les voyait jamais. Toute l'asymétrie qui a coûté du temps était là, et avec elle la garantie que les supprimer ne pouvait pas casser un build. Mesuré : flutter analyze sur le package passe de 67 à 3 issues, et l'analyse globale de manager-app de 68 erreurs de bruit à 3 erreurs, toutes dans test/widget_test.dart (cassé et connu) — flutter analyze redevient un feu vert utilisable, ce qui débloque les lots design. flutter build web ✅ avant comme après.
mymuseum-visitapp/lib/api/openApiTest.dart générait depuis son propre swagger vers un manager_api_new local — un second client divergent dans un repo qui consomme celui de manager-app par path: — et tablet-app/lib/api/openApi.dart faisait de même sous un autre nom de fichier, ce qui lui avait permis d'échapper à toutes les recherches sur « openApiTest ». Plus un résidu tablet-app/manager_api_new/ réduit à un pubspec.lock, non suivi par git, daté d'avril. Les trois sont supprimés : ce ne sont pas des fichiers morts, c'était le moyen d'écraser les éditions manuelles — onboarding_api.dart, le câblage d'AIApi, le mapping isGood → isCorrect. Contrôle de non-régression : grep -rl "@Openapi" */lib doit rester vide.
SectionMap.MapMapProvider, 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. Il est donc conservé, et manager-app cesse de laisser croire qu'il vaut pour le web : mention explicite sous les deux sélecteurs. Zéro ligne côté visitapp-web — Leaflet inconditionnel était déjà le comportement ; ce qui manquait n'était pas du code mais une phrase honnête dans l'interface.
PdfViewer existant et son proxy /api/pdf — le contournement CORS était déjà écrit, il n'y avait qu'à le brancher. Côté Flutter, la carte indiquait le mauvais endroit : showElementForResource fait un return CachedCustomResource(...) avant son switch, donc tout ce switch est du code mort depuis un moment — ajouter le cas PDF là n'aurait rien changé et aurait été « vérifié » sans rien prouver. Le vrai dispatcher est CachedCustomResource, et il a deux switch (ressource distante / fichier déjà téléchargé pour l'offline) qui tombaient tous deux sur Text("Not supported type") : les deux sont traités, le distant devant télécharger avant d'afficher puisque PDFView n'accepte qu'un chemin de fichier. Au passage : l'enum du client s'appelle ResourceType.Pdf, pas PDF. npm run build ✅, flutter build web ✅ — le volet mymuseum ne tient que sur l'analyse, son APK ne compilant pas.
number0/1/2, et le front finissait donc par écrire ?.value == 2 ? 'Puzzle' : … — lisible seulement si l'on connaît l'ordre de l'enum serveur par cœur. Des alias nommés d'après le serveur (simple, multipleChoice, puzzle) sont ajoutés à la main dans question_type.dart, sans toucher à values : ce sont les mêmes trois valeurs, pas de nouvelles. Les deux sites visés par le plan sont corrigés, plus les 7 usages number* du dialogue de question — il ne reste aucun QuestionType.number dans les trois apps.
Navigator.pop sur « Annuler » sans rien demander, pas un — Parcours, Étape et Question, chacun jetant tout ce qui était saisi sous lui. Le plus coûteux est le plus profond : une question de quiz avec ses réponses. Ce n'était pas « la croix » comme le disait cette carte, c'était le bouton Annuler, et le clic hors fenêtre était déjà neutralisé — le vrai chemin de perte restant est le retour arrière du navigateur, couvert par PopScope. Détection des modifications par instantané JSON, pris après la première frame : le dialog Question normalise ses réponses pendant sa construction, un instantané pris avant aurait déclaré « modifié » une fenêtre intacte. Réutilise showConfirmationDialog plutôt que d'ouvrir un second composant de confirmation. 3 clés i18n FR/EN/NL, flutter build web ✅. Retiré le jour même par la carte suivante — c'était l'issue prévue : un garde-fou n'a plus d'objet quand il n'y a plus rien à perdre.
showNewOrUpdate… sont supprimés au profit d'un rail d'étapes toujours visible à gauche, d'un panneau de détail à droite, et d'une question qui se déplie sur place au lieu d'ouvrir une quatrième fenêtre : la profondeur maximale retombe à 2 — l'éditeur, puis la traduction. Les champs sont d'abord sortis en trois widgets autonomes (ParcoursFields, EtapeFields, QuestionFields) — c'est ce qui a rendu la refonte possible sans tout réécrire, et ce qui resterait transposable si l'écran plein cadre revenait un jour.
Plus de bouton « Sauvegarder » : tout part à la saisie (débounce 700 ms ; les ajouts, suppressions et réordonnancements partent sans attendre). Deux pièges du backend, trouvés en lisant le contrôleur plutôt qu'en le supposant : 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 seraient dupliquées ; et comme les questions n'ont pas d'endpoint propre (c'est voulu, elles partent avec leur étape), leur id entier est récupéré après coup par order, seul repère stable entre la liste locale et celle du serveur. Le parcours est créé à la première modification, pas à l'ouverture : une fenêtre neuve refermée intacte ne laisse rien en base. Si une écriture échoue, 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, flutter build web ✅. Reste la vérification à l'œil (DB5).
git status --porcelain sur les huit repos : vide partout, et aucun ahead/behind face au remote. Le lot 3 RAG est dans 18e4240, le socle visuel et l'écran Guide IA dans eb8e849. Ce qui rendait le point urgent — neuf fichiers non suivis qui n'existaient nulle part ailleurs et disparaissaient au premier git checkout — n'existe plus. À ne pas re-planifier : c'était l'étape 1 du lot A.
claude design/, JS interactif compris. Même découpage que ce tableau : contenu dans le repo, diffusion par artifact. Constat en ouvrant les fichiers : Guide IA et Statistiques déclarent des tokens rigoureusement identiques — c'était déjà un design system, jamais porté en Dart — et --brand #264863 est kPrimaryColor.
lib/Components/ : onze tailles différentes (9 à 25 px), aucune échelle, constants.dart portant encore // TO FILL WITH CORRECT COLOR et deux styles. L'échelle, les espacements et les rayons sont extraits du CSS des maquettes ; les couleurs restent celles de l'app — un écran neuf se fond dans manager-app, il n'y ouvre pas une seconde palette. Seules les valeurs sans équivalent existant sont reprises : remplissages discrets, bordures, gris atténué, ambre. Pas de thème sombre. La reprise des écrans existants reste son propre chantier, pas un effet de bord.
/api/AI/chat, pas une simulation — au passage, AIApi était dans le client généré mais exposé nulle part dans client.dart, où les 18 autres façades sont. Câblé. Nouveau GET /api/Ai/knowledge/{id} pour la carte « Ce que connaît votre guide » : tout est agrégé sur ContentEmbedding, donc sur ce qui est réellement indexé — compter les sections publiées donnerait un chiffre plus flatteur et faux, une section désactivée étant purgée de l'index. Le « points d'intérêt » de la maquette devient « morceaux de contenu » : les points d'une carte sont indexés dans le texte de leur SectionMap, les compter à part répondrait à une autre question. 34 clés i18n FR/EN/NL, dotnet test 130/130, flutter build web ✅.
Updateinstance changeait SubscriptionPlanId mais ne recopiait jamais les quotas du nouveau plan — CreateInstance le faisait, pas lui : passer un client de Starter à Premium ne lui donnait ni stockage ni jetons IA. 2) CheckQuota ne bloquait que si quota > 0 : à 0 jeton, ni blocage ni compteur — l'ambiguïté vient du code lui-même, où 0 veut dire illimité pour le stockage et pas d'IA pour les jetons. Une instance Starter avec IsAssistant à true consommait donc gratuitement et sans compteur, et toute instance migrée depuis Mongo arrive à 0 (écart c du §1quinquies). Corrigé en 403 avant tout appel au modèle. 3) BackfillInstanceAsync n'était appelé par personne : branché automatiquement quand AiTokensPerMonth passe de 0 à une valeur, plus un bouton de relance réservé au SuperAdmin dans l'écran Guide IA — outil de réparation, pas fonctionnalité : exposé au client il serait cliqué à chaque réponse décevante, pour un coût d'embedding complet et sans rien améliorer. IBackgroundJobClient injecté au lieu de la façade statique, qui lève sans JobStorage et cassait les tests. 130/130.
IEmbeddingService + GoogleEmbeddingService (batch par 50), et surtout vérifiés contre l'API réelle. Trois choses qu'aucun plan ne disait : le modèle sort en 3072 dimensions par défaut — dimensions: 768 est obligatoire ; les vecteurs réduits ne sont pas normalisés (norme mesurée 0,578, pas 1) et sans normalisation côté client le classement cosinus serait faussé sans lever d'erreur ; et Google ne renvoie pas le champ index du contrat OpenAI. Recherche cross-lingue jouée en base : question NL → contenu NL 0,8145, FR pertinent 0,7982, FR hors-sujet 0,4818. L'écart NL/FR n'étant que de 0,016, le bonus de score par langue est bien nécessaire.
HasAdvancedStats devient le seul différenciateur stats, et l'export PDF est inclus dès qu'il y a les stats. Motif : un dossier de subside est annuel — plafonné à 30 jours le rapport n'était envoyable à personne. Et la landing ne promettait aucune durée (la chaîne « 30 jours » existe dans translations.ts mais n'est affichée nulle part). Migration UnifyStatsRetentionTo13Months — elle met à jour les plans et reprend les instances en SQL, sans quoi les clients actuels seraient restés à 30 jours : c'est Instance.StatsHistoryDays que lit le contrôleur. dotnet test 124/124. ⛔ La purge associée est codée mais inactive jusqu'à la mise en place du pg_dump.
progression_mode ne pouvaient pas le voir — ils couvrent la relecture des booléens, pas le DTO fabriqué par la popup. Aussi : vidéos et audios d'étape rendus en dur comme des images dans les deux apps visiteur, « Quelle ambiance ? » restée en case à cocher, menu « Carte de base » disparaissant sans un mot. À valider — test-plan §19.13 cas 0.
postgis + pgvector (0.8.6, base épinglée par digest), table ContentEmbedding avec index HNSW, 7 colonnes IA/stockage sur Resource, table VisitorQuestion. 62 migrations, dotnet test 124/124, données intactes. Deux surprises : la base locale avait une migration de retard — les colonnes Guide* du lot 1 n'existaient pas, toute requête sur Instances aurait planté ; et un warning de collation (glibc 2.36→2.31) qui désalignait les index texte, corrigé par REINDEX puis REFRESH COLLATION VERSION — dans cet ordre. D'où l'épinglage par digest.
dotnet test 124/124, jamais vu tourner.
pdf Dart déjà présent) et partage les valeurs calculées de l'écran — un chiffre ne peut pas diverger entre l'écran et le document envoyé à la commune. Le test de génération a attrapé deux plantages qui seraient sortis au premier clic. Au passage : VisitEvent porte bien l'AppType, le canal vocal est branché ; et deux puces du sommaire promettaient des données inexistantes (parcours terminés, questions au guide IA), retirées.
dotnet test 124/124. Un seul fichier sans // @dart=2.18 cassait les trois apps Flutter d'un coup.
BuildAuditEntries() modifiait le ChangeTracker pendant son énumération — exception sur toute écriture d'entité auditée. Invisible tant que les tests ne compilaient plus.
IsStepLocked supprimé : il rendait une étape définitivement infranchissable même après réussite.
Relevé et corrigé le 10/09. Les CGU sont désormais publiées sur myinfomate.be/conditions-generales (groupe (legal), page statique), la case de consentement de /signup pointe dessus, et le lien figure aussi dans les pieds de page des 4 langues et dans le sitemap. Deux contradictions du document source ont été corrigées au passage : le §6.1 citait les plans abandonnés (Starter 2 Go / Standard 10 Go) et le §7.1 réservait l'assistant IA aux « plans Standard et Premium ». ⚠️ La validation juridique reste due — carte dédiée.
Instance, client généré étendu à la main, écran avec 35 clés i18n FR/EN/NL. dotnet build et flutter build web passent. Menu conditionné à isAssistant, le même drapeau que la garde d'AiController.
AddRateLimiter natif .NET 8, partition par instance — c'est l'instance qui porte le quota protégé, l'abus chez l'un ne doit pas ralentir les autres — fenêtre fixe 120 req/min, 429 avec Retry-After. Appliqué à chat et translate : les deux consomment des jetons, et translate est atteignable avec la même clé. Ordre du pipeline choisi, pas subi : après UseCors (un 429 posé avant les en-têtes CORS s'affiche comme une erreur CORS et le client ne voit jamais le vrai code) et après UseAuthentication (sinon la partition n'a pas le claim d'instance et tout le monde tombe dans le même seau). dotnet build ✅, dotnet test 203/203. ⚠️ Jamais exercé à l'exécution : le projet n'a aucune infrastructure de test HTTP (pas de WebApplicationFactory), et en monter une pour ce seul contrôle serait disproportionné. Vérification manuelle à faire une fois : une boucle de 130 appels sur /api/AI/chat doit basculer en 429 vers le 121e.
audio/mpeg reconnu à l'écriture des fichiers
La table d'extensions ne mappait que audio/mp3, qui n'est pas un MIME standard : les MP3 servis correctement par le serveur atterrissaient en .unknown sur le device. Corrigé sans attendre le test §21 — c'était un défaut certain, lisible dans le code, pas une hypothèse à mesurer.
deleteSync() commenté, le stockage occupé sur le téléphone du visiteur ne diminuait jamais. Réactivé, borné au répertoire de la configuration. À confirmer au §21, seul juge de « elle ne supprime rien d'utile ».
⚠️ Deux prémisses du plan étaient fausses. « Les deux chemins de téléchargement divergent » (le motif Create/Upload de C1/C3) : il n'y a qu'un seul chemin vivant — les lignes 328-461 sont dans un bloc commenté, et cleanLocalResources n'est appelée nulle part. Et cette fonction reste désactivée délibérément : la table locale resources n'a pas de colonne configurationId, elle est globale à toutes les visites — l'activer sur les ids d'une seule aurait effacé les lignes des autres, pour rien, puisque le rendu retrouve le fichier en listant le répertoire.
dateUpdate, de bout en bout
dateUpdate exposé dans ResourceDTO (champ DTO, pas une colonne — le gel du lot B tient), client manager_api_new édité à la main, colonne ajoutée à la base locale du device (v3 → v4), et le filtre passe de « le fichier est là » à une comparaison de dates. dotnet test 205/205 (2 tests ajoutés), flutter build web ✅, APK dev ✅, tablet-app ✅.
⚠️ La prémisse était fausse, et ça change ce que D2 répare. « Une image remplacée dans le CMS ne remonte jamais » : ce cas n'existe pas — Upload génère un nouvel id à chaque téléversement et le blob vit en pictures/{instanceId}/{resourceId}, donc remplacer une image donne un nouvel id et une nouvelle URL, que l'ancien filtre téléchargeait déjà. ✅ Le vrai cas de péremption est celui que D3 vient de créer : les MP3 déjà sur les devices y sont en .unknown et se déclaraient à jour. Sans D2, D3 ne réparait que les installations neuves.
⚠️ Deux pièges tranchés en écrivant : la montée 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 fait un UPDATE de la ligne entière quand l'id existe.
Clos le 12/09. Le chantier tenait en trois lots, et les trois sont retombés : XR-1 le 11/09 (Device.AppType, migration 20260911135527_AddAppTypeToDevice, DeviceController.Create qui résout l'ApplicationInstance sur appType, ApiKeyAppType.VrApp en fin d'enum, 224 tests au vert), XR-3 qui était déjà fait sans que le plan le sache (export ouvert aux apps depuis 9cc45c5), et XR-2 le 12/09.
L'écran XR est une coquille à deux sous-onglets : « Configuration », qui réutilise AppConfigurationLinkScreen tel quel — il est déjà générique sur l'appType, zéro code neuf —, et « Casques », la grille avec pincode d'appairage, état connecté, batterie, version et dernier vu. i18n FR/EN/NL.
⚠️ Deux affirmations du plan étaient fausses, relevées dans le code. La grille kiosk ne liste pas des Device mais des AppConfigurationLink : le filtre par canal était déjà implicite, et le paramètre appType de /api/device ajouté par XR-1 ne sert pas à cet écran. Et batterie / version / dernier vu ne sont pas dans DeviceDTO, seulement dans DeviceDetailDTO — donc un appel de détail par casque, assumé sur une flotte qui se compte en unités.
Reste au plan deux puces purement documentaires de XR-3 : figer le JSON d'export comme contrat public versionné, et décider si l'export doit rendre toutes les langues d'un coup pour un casque en borne. Le prochain vrai coût est XR-4, l'app Unity — carte séparée.
Clos le 12/09. Trois valeurs ajoutées en fin de ResourceType — Image360 (11), Video360 (12), Model3D (13) — sans migration : la colonne est déjà integer, une valeur d'enum n'y change rien. Un test les verrouille une par une, parce que le commentaire du code avertissait du risque (« les PDF deviendraient des JSON ») sans que rien ne l'empêche.
La carte disait « une valeur d'enum et un branchement dans l'affichage ». Il manquait les deux morceaux qui comptent.
Un : comment on déclare une 360. Aucune extension ne le dit — un panorama est un .jpg comme un autre. La pastille de type du sélecteur de médias devient donc cliquable : elle bascule Image ↔ Image 360°, Vidéo ↔ Vidéo 360°, et se colore quand c'est immersif. Le .glb, lui, se déduit seul : un GLB n'est jamais autre chose qu'un modèle 3D.
Deux, et c'est le vrai piège : la compression. ImageCompressor ramène toute image à 2560 px de côté long. Une équirectangulaire de 8192×4096 y perd les trois quarts de sa définition — invisible sur un écran, une bouillie dans un casque où elle couvre tout le champ de vision. Les trois types en sont exclus, sur les deux chemins d'upload, qui avaient déjà divergé deux fois par le passé.
✅ Bonne surprise : le prérequis « le backend ne sait pas écrire dans le bucket », qui bloque le Studio et le TTS, ne s'applique pas ici — manager-app pousse directement dans Firebase Storage puis enregistre l'URL. L'ancienne route serveur /upload plafonne à 1,5 Mo, mais elle n'est plus le chemin utilisé.
➡️ Ce que ça débloque : E6 du lot XR-4, la lecture 360° dans le casque, écrite dans la foulée (SkyboxView, image et vidéo dans le même shader panoramique). C'était le dernier prérequis du POC VR. ⚠️ Skybox/Panoramic doit être dans Always Included Shaders, sinon le ciel sort magenta sur le casque et correct dans l'éditeur. Plan de test §25.7 — sa première moitié se joue sans casque.
Non fait, et assumé : l'exploitation du Model3D. La valeur existe, mais un modèle 3D demande encore un type de section dédié, une position 3D sur les points d'intérêt et un éditeur de placement — c'est E7, et le §9 du plan VR le décrit.
Trouvé et corrigé le 12/09, en écrivant l'appairage du casque : il butait sur le même mur.
DeviceController porte [Authorize(InstanceAdmin)] sur la classe, et Create n'avait aucune exception. Or une clé d'API ne porte que AppRead et Viewer (ApiKeyAuthenticationHandler), et tablet-app ne s'authentifie jamais autrement — aucun appel d'authentification dans tout le repo. POST /api/device répondait donc 403 à toute tablette.
Depuis quand : commit a452f4a du 13/03/2026, dont le message dit lui-même « need to be tested ». Pourquoi personne ne l'a vu : une tablette déjà appairée ne rappelle jamais Create. Le parc existant continuait de fonctionner ; seul un nouvel appareil échouait — c'est-à-dire exactement ce qu'on ne fait pas tous les jours.
Correctif : Create passe en [AllowAnonymous] + [RequireAppKey], le filtre posé le même jour pour les routes de contenu. Le cloisonnement, lui, était déjà écrit juste en dessous — une clé ne peut créer un appareil que dans son instance.
GET /api/Device/{id}/detail était fermé aux clés lui aussi — or c'est le premier appel d'une tablette qui démarre (tablet-app/lib/main.dart:46) : celui qui lui dit quelle configuration afficher. Une tablette déjà appairée ne retrouvait donc plus son contenu au redémarrage. Ouvert de la même façon.tablet-app/lib/main.dart:38 reconstruisait son client sans clé — Client(host) au lieu de Client(host, apiKey: …) — alors que la clé est persistée en base locale (TabletAppContext.toMap). Une ligne, et tous les appels d'après partaient anonymes.Les deux se tenaient : la route refusait les clés, et l'app n'en envoyait pas. Corrigés ensemble.
⚠️ À vérifier sur le terrain : appairer une vraie nouvelle tablette, et redémarrer une tablette déjà appairée. Le correctif est couvert par les tests, mais les tests appellent le contrôleur directement — aucun filtre d'autorisation ne s'y exécute, donc ils ne prouvent rien sur l'accès lui-même.