MyInfoMate — tableau de chantiers

Vue 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.

Mise à jour · 2026-08-13
Postgres v3 · pas encore en prod
1Urgent
1Migration v3
1Bugs ouverts
6À tester
24Planifié
8Bascule prod
55Fait récemment
Domaine
Horizon

Urgent

1
infraSécurité

Port PostgreSQL exposé publiquement

À fermer via Traefik/Docker. Accès DBeaver par tunnel SSH uniquement.

todo-features.md — Sécurité réseau

Migration v3

1
manager-appcode mort depuis C3

Retirer la suppression de blob de manager-app

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.

v1-plan.md lot C — C6 · STATUS.md §1sexies

Bugs ouverts

1
visitapp

Échecs de téléchargement silencieux

Un fichier raté fait un print et passe. La visite est annoncée téléchargée alors qu'elle est incomplète.

v2/offline-visit-plan.md

À tester

6
manager-appJamais lancé

Écran Statistiques refondu + export PDF

É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.

test-plan.md §8bis, §8ter, §8quater
§21En premier

Visite hors ligne sur device

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/08audio/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.

test-plan.md §21
§18Jamais exécuté

Onboarding self-service complet

Inscription, 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.

test-plan.md §18
§19.13

Chasse au trésor / carnaval — cas E

Le scénario le plus complet : SectionMap + SectionParcours + géodéclenchement + questions + mode jeu, d'un seul tenant.

test-plan.md §19.13
§19

Parité par type de section

« Tout ce que je configure est-il visible ? » — le chapitre qui valide les 13 types sur les deux apps visiteur.

test-plan.md §19
§0

Porte d'entrée build

5 commandes, bloquant absolu. flutter analyze et next dev ne prouvent rien — seuls les vrais builds disent la vérité.

test-plan.md §0

Planifié

24
visitappcode mortV2

Nettoyer downloadConfiguration.dart

Trouvé 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.

v1-plan.md lot D — D4 · STATUS.md §K5
3 reposContredit le code

Les swagger.yaml embarqués sont périmés

Relevé 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é D2
visitapppiège latentV2

La colonne path de la table resources est écrasée à vide

Trouvé 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.

v1-plan.md lot D — D2
après prodrelationnel

Reprendre contact avec Louise Smets — Musée d'Ixelles

Une 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 bascule
priorité 1V2

Rapport de stats — envoi mensuel automatique

Le 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.

plan-import-ia-stats-subsides.md §1
manager-serviceRGPD · lot J

AuditLog n'a aucune purge

VisitEvent 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à.

v1-plan.md lot F · STATUS.md §1sexies
priorité 2Exécuté 10/08 — 2150 morceaux

RAG sur le contenu CMS — vector store, jobs, outil SearchKnowledge

Persona 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 > 0plan-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.

STATUS.md §1quater — lot 3 · v2/rag-indexing-trigger-decision.md · v2/rag-pgvector-integration-plan.md §3, §4
manager-appFront fait · backend à faire

RGPD & conformité — lot J, dernier avant la bascule

Reporté en fin de backlog le 11/08. Tenable parce que la porte « rien en prod avant la fin du backlog » tient : d'ici là la journalisation ne tourne que sur des bases de développement, aucun visiteur réel n'est concerné. Si un déploiement anticipé était décidé, ce lot redeviendrait bloquant.

Déjà fait le 11/08 : CGU §8 réécrites (elles décrivaient un service qui ne collectait que des statistiques anonymes, citaient des durées de plans abandonnés, et ne mentionnaient aucun sous-traitant alors que la question du visiteur part chez Google), texte d'information visiteurs en FR/NL/EN, et purge à 90 jours active sans condition de configuration — une durée écrite dans un contrat n'est pas un réglage.

Reste : la table d'agrégats de thèmes (les CGU promettent que les regroupements survivent à la purge, or ThemeId est une colonne de la ligne supprimée — au 91e jour le client perdrait tout), le job de regroupement, un interrupteur de collecte par instance (⚠️ le client est responsable de traitement mais ne peut pas refuser la collecte), la mention à afficher côté visiteur, la relecture juridique du §8 et la vérification des conditions réelles de Google.

Périmètre tranché le 12/08 : job de regroupement et table d'agrégats, les deux. Donc le §8.4 des CGU n'est pas à amender — c'était l'alternative. La table ne porte que des compteurs (instance, mois, thème, n), aucune donnée personnelle : elle survit légitimement à la purge. ⚠️ 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. C'est le plus gros poste du lot, ~3-4 jours.

v1-plan.md — lot J · cgu-myinfomate.md §8 · mention-information-visiteurs.md
visitapplunettesNouveau 12/08

Miroir de la conversation vocale dans le chat

Tranché le 12/08 : ouvrir l'assistant pendant que les lunettes tournent doit montrer la même conversation, transcrite au fil de l'eau. Une conversation, deux surfaces.

⚠️ Le coût n'est pas l'affichage, c'est l'unification des historiques. MyInfoMateLlmClient construit son propre AssistantService (maxHistory: 6), distinct de celui du chat écrit : poser une question aux lunettes puis ouvrir le chat donne aujourd'hui un interlocuteur qui ne sait rien de ce qu'on vient de demander. La transcription, elle, est déjà là — VoiceOrchestrator expose lastTranscription et lastTtsText, personne ne les affiche.

Bénéfice de bord : VisitorQuestion.ConversationId est un GUID de session, « seul lien entre deux tours ». Deux historiques = deux conversations en base pour un visiteur qui a juste changé de surface. Unifier répare aussi les agrégats du Guide IA, donc ce que le job de thèmes du lot J regroupera. ⛔ Écarté : l'affichage passif dans VoiceModeSheet (2-3 h) — ça montrait que ça marche sans réparer la coupure. ~1 à 2 jours.

⚠️ Ne rien redessiner, l'UI existe. GlassesStatusWidget est monté en haut à droite de l'accueil (home_3.0.dart:465) : état par couleur, pastille de mode actif, tap → VoiceModeSheet (connexion, choix de mode, désactivation). GlassesDebugPanel existe et se garde — c'est l'outil de test. Seul écart avec « tout le temps présente », et il est volontaire : l'icône se cache si applicationInstanceDTO?.isAssistant != true (GlassesStatusWidget.dart:32) — invisible chez MDLF et le Fort, visible sur l'instance MyInfoMate. À savoir avant de tester chez un client et de croire à un bug.

v1-plan.md lot F — miroir vocal
manager-appJamais ouvert

Écrans du lot design — vérification à l'œil

Deux é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.

v1-plan.md — DB5
priorité 2V2

Ingestion documentaire + OCR

PdfPig avec cascade OCR Gemini (~0,03 $ / 100 pages). Word, PowerPoint, images. Legacy .doc/.ppt refusés à l'upload.

v2/rag-pgvector-integration-plan.md
priorité 3V2

TTS pré-généré — audioguide multilingue

Le produit que les musées achètent depuis toujours, à coût marginal quasi nul. Aucun concurrent de cette gamme ne le fait.

v2/tts-pregenerated-plan.md
priorité 4V2

Génération de visites à la demande

« 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 §3
guide IAV2

Guides multiples & guide thématique

Plusieurs 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 — PersonaConfig
visitapp-webBloque l'offre Essentiel

Déployer le visiteur web — app.myinfomate.be

L'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 — À faire
tablet-appV2

Kiosk web (Flutter Web)

Extension 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.

architecture-web-saas.md — Kiosk web
tablet-appK9 · élargi 12/08

Repasse visuelle de la borne — bento, types, orientation

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.html
produitV2

SectionForm — formulaires personnalisés

Nouveau type de section + table de réponses anonymes, constructeur de formulaire et page de résultats agrégés. Reporté en V2 le 07/08 : purement additif, aucun impact sur le schéma existant — rien n'oblige à le faire passer avec la migration.

todo-features.md — SectionForm
produitV2

Ressource 360° — images panoramiques

Une valeur d'enum (Panorama360) et un branchement dans l'affichage des ressources : s'affiche partout où une ressource s'affiche, sans toucher aux sections. Reporté en V2 le 07/08 — le plus petit des trois, à reprendre en premier.

todo-features.md — Ressource 360°
produitV2

AR image tracking (Mind AR)

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.

todo-features.md — AR
produitV2 · nouveau 12/08

Écran SuperAdmin — add-on IA & canaux d'une instance

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.

STATUS.md §1sexies — add-on IA
polishV2

Talking head — avatar animé

Trois frames par persona, lipsync sur les timestamps Google TTS. Quelques jours, effet démo. Bouche-trou entre deux chantiers.

v2/talking-head-plan.md
XRV2 · subventionné

Meta Quest — borne VR

Pilote presse uniquement, quand le SDK sort de preview. Profil d'appel à projets, pas de ligne produit. ⚠️ Les Ray-Ban ne sont plus dans cette carte : elles passent en V1 le 12/08 (carte suivante).

roadmap.md — section XR
visitapplunettesNouveau 12/08

Canal Vocal des stats — l'émission côté visiteur

Décidé le 12/08 : les Ray-Ban entrent en V1. Activation manuelle, instance par instance — au démarrage la seule instance concernée est MyInfoMate, celle de test, pour observer des stats vocales réelles avant de proposer quoi que ce soit. Aucune promesse externe ne dépend de cette date.

⚠️ L'écran est déjà fait, c'est l'émission qui manque — donc zéro ligne dans manager-app : statistics_screen.dart traite déjà AppType.Voice (libellé :189, part du vocal :205-210, KPI conditionnel :533-541, takeaway :480). Tout le travail est dans mymuseum-visitapp.

Trois trous mesurés le 12/08. (1) MyInfoMateLlmClient:40 envoie AppType.Mobile alors que son commentaire :9 annonce Voice. (2) StatisticsService est construit en dur avec appType: 'Mobile' (home_3.0.dart:715), son champ étant documenté // "Mobile" or "Tablet". (3) Le plus lourd : le flux lunettes n'émet aucun VisitEvent — tous les track() sont dans des écrans d'interface ; Services/Glasses/, wake_word_service et geo_beacon_trigger_service n'en appellent aucun. Une visite vocale ne produit aujourd'hui ni session, ni section vue, ni durée.

Périmètre tranché : session + contenu lu à voix haute. Une session Voice à l'activation, un sectionView quand le guide lit réellement une section (QR, beacon ou question). ⛔ Pas d'événement par question : elles sont déjà dans VisitorQuestion et remontent dans l'onglet Guide IA — les compter ici serait le même fait dans deux écrans. Ce périmètre fait marcher les KPI existants et l'export PDF sans retouche.

Le « fallback Voice → Mobile » n'existe pasguide-ia-screen-plan.md:226 l'affirmait et le commentaire d'AiController:356 le répétait : les deux étaient faux et se confirmaient mutuellement. Le code fait un FirstOrDefault sur le canal exact puis Forbid() (:357-361) : envoyer Voice au chat sans ApplicationInstance de ce type rendrait 403 à chaque question, extinction silencieuse des lunettes. ✅ Le chat reste donc en Mobile : les lunettes tournent dans l'app mobile, qui a déjà chargé son canal — un canal Voice la ferait jongler entre deux, piège déjà rencontré en K2 avec getConfigurations.

CORRECTION du 12/08, quelques heures après : les VisitEvent ne portent pas Voice non plus. Le vocal n'est pas un canal, c'est un attribut. La décision du miroir vocal (carte suivante) rend explicite qu'un visiteur passe des lunettes à l'écran dans une même session. Or StatisticsService génère un seul sessionId par lancement, et AppTypeDistribution compte une entrée par session en tranchant l'ambiguïté sur l'événement le plus ancien (StatsController:196-203) : commencer au doigt puis mettre les lunettes ⇒ session comptée Mobile, vocal invisible ; l'inverse ⇒ session comptée Voice, navigation tactile comprise. Le KPI dirait « sessions démarrées en vocal », pas « part du vocal ».

Retenu : marquer les événements consommés à la voix dans VisitEvent.Metadata — colonne JSON déjà existante, déjà exploitée par GetSummary pour six agrégats : aucune migration, le gel du lot B tient. KPI juste : « part des sessions ayant utilisé le vocal ». C'est la conception déjà appliquée ailleursVisitorQuestion n'a pas de canal vocal, il a IsVoice à côté de son AppType. AppType.Voice retrouve son sens d'origine : un déploiement lunettes autonome, cas pour lequel l'enum a été créé et qui n'est pas celui-ci. ⚠️ Conséquence : « zéro ligne dans manager-app » devient faux_voiceSessions lit appTypeDistribution['Voice'] (statistics_screen.dart:205-206) et devra lire un nouvel agrégat. ~3 h.

⚠️ Deux réserves qui survivent : la branche Meta-Rayban-Test n'est toujours pas mergée (question déjà ouverte avant K6, et plus pressante puisqu'on ne publie plus un POC mais une fonction V1), et ne pas vendre l'add-on avant la bascule TTS ElevenLabs → Gemini.

v1-plan.md lot F — canal Vocal · v2/guide-ia-screen-plan.md

Bascule prod

8
étape 0Sécurité — avant l'étape 16

Rotation des secrets committés en clair

Clé 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.

security/audit-securite-manager-service.md · v1-plan.md L1, I0
étape 15Sans risque

Dry run + comparaison des comptages

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.

STATUS.md §1quinquies — étape 15
étape 16-17Après rotation des secrets

Créer l'environnement prod Postgres

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.

STATUS.md §1quinquies — étapes 16-17
étape 18Filet du jour J

pg_dump avant / après + cron quotidien

Noté comme « à faire » depuis juillet et jamais fait. Ici ça cesse d'être une bonne pratique : c'est la seule chose qui permette de recommencer si la bascule tourne mal.

STATUS.md §4 · §1quinquies — étape 18
étape 19

Rejouer pour de vrai, instance par instance

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.

STATUS.md §1quinquies — étape 19
étape I9nouveau 12/08

Poser Firebase:StorageBucket, puis lancer le backfill

Deux 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.

v1-plan.md lot I — I9 · STATUS.md §1sexies
étape 20Le vrai test

Vérifications post-bascule

Login manager-app, une app visiteur avec sa clé API, un parcours, une carte, un PDF, un quiz avec ses questions. Les défauts « silencieux » ne se voient qu'ici — pas dans le rapport de migration.

STATUS.md §1quinquies — étape 20
étape 21-22

Bascule par coexistence sur deux serveurs

Dé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 coexistence

Fait récemment

Cinquante-cinq chantiers clos entre le 5 et le 13 août 2026.

Les prompts du mode proactif ne polluent plus « Ce que demandent vos visiteurs » Le dernier bloquant du proactif. 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 ✅).
Déclenchement proactif câblé, et M3 fermé avec lui Quatre branchements : la garde, le cycle de vie, les points GPS, le réglage visiteur. APK 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.
La voix choisie par le client n'était pas celle qu'entendaient les visiteurs Le sélecteur Viva / Marco de manager-app écrivait dans le vide. Il existe depuis le 07/08 et enregistre 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.
Trois services morts supprimés — et une conclusion de STATUS retournée 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.
FAQ de la landing — le plan à 179 € s'appelait encore « Bundle » 41 occurrences en 4 langues (FR, EN, NL, DE) dans 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-abonnementenPremium-abonnementen, Bundle-PlänePremium-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.
Lot F — le bouton du portail Stripe et le diviseur de questions (manager-app) Portail de facturation : le bouton manquait, et l'écran n'affichait rien du tout hors essaiif (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 ✅.
Lot F backend — portail de facturation Stripe et coût réel d'une question Customer Portal : 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.
C4 — compression des images à l'upload, 2560 px / JPEG q82 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.
K8 — la borne revient à l'accueil après 5 min sans interaction Un visiteur part sans fermer l'écran ; le suivant trouvait le contenu du précédent. Écrit une seule fois, dans 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.
La borne couvre 12 des 13 types — SectionArticle (K7) et SectionEvent (K3) livrés 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.
K5 — l'APK 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.
Lot médias terminé côté serveur — backfill (C2) et quota autoritaire (C3) C2 : 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 partUpload (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.
tablet-app construit à nouveau — APK produit pour la première fois depuis avril 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.

Le cran le plus instructif : un 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.
tablet-app rebranché sur le contrat d'API de Postgres v3 (K2) Les 132 erreurs Dart que le Gradle masquait sont tombées : 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.

⚠️ Deux bugs latents trouvés — la raison pour laquelle un chercher-remplacer aurait été dangereux. (1) 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.
tablet-app : dix crans d'outillage Android rattrapés Le premier vrai build depuis des mois. Chaque erreur dictait le correctif suivant : Gradle 7.5 → 8.11.1 (le plugin Kotlin exigeait ≥ 7.6.3), AGP 7.2.0 → 8.5.0 puis 8.7.3 (minimum Flutter) puis 8.9.1 (réclamé par 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.
Lot F côté manager-service : les quatre chantiers de dette backend, en une passe 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é serveurEntityType 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.
Écran d'audit log et compteur d'utilisateurs — le lot F côté manager-app Deux chantiers dans la même passe, 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é.
Alerte de budget GCP posée Le bucket est en Europe : aucun quota gratuit, facturation dès le premier octet. L'alerte est posée dans la console GCP — rien à vérifier dans le code, et c'est la seule protection réelle contre une surprise de facture pendant les tests de charge du lot H.
Le rattrapage d'indexation ne brûle plus le quota Gemini en boucle 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.
La visite hors ligne embarque enfin ses médias Le 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.
MigrationController — trois des huit écarts n'existaient pas Le plan les décrivait en « perte de données ». L'export Mongo, lu plutôt que supposé, dit qu'il n'y a rien à perdre. (a) « 11 types de section sur 13 » : les 327 sections de l'export ne contiennent que les types 0 à 10 — 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.
MigrationController — les cinq écarts réels, corrigés (b) 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.
Le schéma est gelé — lot B, et le lot G peut enfin commencer Le point d'articulation du plan : tant qu'un changement de modèle restait ouvert, réécrire MigrationController revenait à l'écrire deux fois. Une seule migration EF porte les trois changements. SectionMap.MapResourceIdIconResourceId, 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.
Le backdoor d'authentification, et deux fuites de moins Trouvé en cherchant le « backdoor #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.
Le client généré nettoyé — et la règle « pas de régénération » enfin verrouillée Le compte exact : 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.
Trois déclencheurs de génération, pas un Le verrou ne tenait que dans manager-app. 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 manuellesonboarding_api.dart, le câblage d'AIApi, le mapping isGoodisCorrect. Contrôle de non-régression : grep -rl "@Openapi" */lib doit rester vide.
Fournisseur de carte en web — tranché, et pas comme prévu Option a retenue : le web reste sur Leaflet quoi que le client configure. Ni MapBox (token payant) ni Google Maps JS (clé facturée + seconde librairie carto à maintenir) — aucun coût récurrent ajouté avant la prod. Mais l'option annoncée disait « retirer le champ pour les instances web », et c'est impossible : le fournisseur est porté par la section (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.
PDF en média d'étape — et le dispatcher qui n'était pas le bon Les trois volets sont faits. La liste de types du sélecteur de médias devient un paramètre : l'étape passe désormais « types du slider + PDF » là où c'était figé en dur. Côté web, le cas PDF réutilise le 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.
QuestionType — la fin des comparaisons par entier brut Descendu du lot B, où il ne restait plus qu'une propreté de front. La cause était dans le client généré : le générateur nomme les valeurs 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.
Parcours — on ne perd plus son travail sans être averti Le défaut était pire que décrit : trois dialogues faisaient 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.
Parcours — les quatre fenêtres empilées deviennent une seule Option A livrée, l'arbitrage tombe avec. Les trois 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).
Plus rien sur un seul disque — les 9 repos sont propres La carte la plus ancienne de la colonne « Urgent » se referme. L'alerte du 09/08 (« rien depuis le 17/07 », 75 + 40 + 14 + 4 fichiers) avait déjà été révisée le matin du 11/08 en « ~41 fichiers dont 9 non suivis » ; les deux versions sont maintenant périmées. Vérifié au 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.
Les maquettes sortent du compte Claude et entrent dans le repo Cause diagnostiquée d'un défaut qui se répétait : les trois maquettes vivaient dans des artifacts d'un autre compte, illisibles depuis la machine de dev — chaque session redessinait. D'où un écran Guide IA « livré » à la moitié de sa spec et des stats d'assistant à zéro. Le HTML est rapatrié dans 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.
Socle visuel — 11 tailles de police deviennent 11 rôles Mesuré dans 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.
Écran Guide IA — la coquille à onglets, et ce que le guide sait vraiment L'écran passe de cinq cartes en colonne à la structure de la maquette : deux onglets, deux colonnes. L'aperçu de conversation interroge le vrai /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 ✅.
Le changement de plan donne enfin ce qu'il vend Trois défauts qui se tenaient la main. 1) 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.
La dimension 768 n'est plus un pari 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éfautdimensions: 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.
L'historique des stats passe à 13 mois pour tous L'axe « durée » disparaît du découpage commercial : 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.
Configuration des parcours — 4 correctifs Relecture du 09/08 de l'écran refondu le 06/08. Le plus sérieux : un parcours créé sans toucher à la question de progression affichait « Dans l'ordre » et enregistrait « Libre ». Les 12 tests de 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.
Le schéma du RAG est en base Image 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.
Le guide lit enfin sa configuration L'écran Guide IA ne pilote plus dans le vide. En ouvrant le fichier : quatre blocs de prompt et non deux — le relevé s'était arrêté au scope configuration. Les quatre lisent le nom, la personnalité et les messages de repli du client ; le ton imposé (« tu es chaleureux ») est retiré, la politique de repli divergente de la ligne 550 réconciliée. Deux prompts sur quatre n'avaient aucune règle hors-sujet — sur web et mobile, un visiteur pouvait demander une recette de tarte. Le tirage du repli est fait côté serveur pour que la phrase du client soit reprise mot pour mot. dotnet test 124/124, jamais vu tourner.
Écran Statistiques refondu, export PDF compris Les 7 défauts corrigés, aucun changement backend : barres horizontales monochromes, filtres sur une seule ligne avec le volume de chaque canal, règle mono-canal, 4 KPI portant chacun leur variation, bandeau « à retenir », courbe en aire. La période précédente s'obtient en rappelant le même endpoint. Le rapport PDF se génère dans le navigateur (paquet 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.
Les builds sont débloqués 3 apps Flutter + visitapp-web + dotnet test 124/124. Un seul fichier sans // @dart=2.18 cassait les trois apps Flutter d'un coup.
Bug backend révélé par les tests BuildAuditEntries() modifiait le ChangeTracker pendant son énumération — exception sur toute écriture d'entité auditée. Invisible tant que les tests ne compilaient plus.
Assistant IA en web + alignement mobile Débloque l'add-on IA sur le plan Essentiel, qui est web-only. Suggestions dérivées du contenu réel, zéro configuration client.
Refonte de la progression des parcours 9 booléens sur 3 niveaux remplacés par 3 questions. IsStepLocked supprimé : il rendait une étape définitivement infranchissable même après réussite.
Centrage de carte, horaires, icônes M1, M2 et W2 corrigés — des champs que le client saisissait sans qu'ils aient le moindre effet.
Audit de cohérence de la doc Le LLM annoncé était Claude à 4 endroits (c'est Gemini), l'infra email « inexistante » (Resend est en place), et deux docs se contredisaient sur l'état des builds.
CGU reprises et landing corrigée Les CGU décrivaient des plans abandonnés (Starter/Standard/Premium 69/99/199 €). Alignées sur la landing, avec un quota IA défini en tokens renvoyant au back-office plutôt qu'un chiffre figé dans un contrat. Landing : « req/mois » → « questions de visiteurs / mois » en 4 langues.
Écran Guide IA — onglet Configuration livré Migration additive (4 colonnes nullable), champs sur 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.
Rate limiting sur les endpoints IA La clé API publique est lisible dans le navigateur dès que visitapp-web est en ligne, et rien n'empêchait d'y boucler jusqu'à vider le quota de jetons d'un client. 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.
D3 — 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.
D4 — purge des fichiers obsolètes réactivée La liste des ressources utilisées était calculée puis jetée : 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.
D2 — filtre de fraîcheur sur 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 pasUpload 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.
Trois plans écrits, deux écrans maquettés Médias & stockage, visite hors ligne, écran Guide IA, écran Statistiques. Le découpage V1/V2 est tranché : le RAG sur contenu CMS part en V1, l'ingestion documentaire en V2. Les deux écrans maquettés sont désormais implémentés.