Cinquante-et-un chantiers clos entre le 5 et le 13 août 2026.
+ Cinquante-quatre chantiers clos entre le 5 et le 13 août 2026.
+
+ 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-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.
diff --git a/test-plan.md b/test-plan.md
index 0b423af..24e6e0c 100644
--- a/test-plan.md
+++ b/test-plan.md
@@ -722,7 +722,14 @@ Points de vigilance à ne pas oublier au moment de tester :
## 21. Visite hors ligne — diagnostic terrain
> Ajouté le 2026-08-07. **À jouer en premier** : conditionne l'urgence du chantier [v2/offline-visit-plan.md](v2/offline-visit-plan.md).
-> Analyse du code : le `switch` de collecte des ressources est commenté dans `ConfigurationController.Export` **et** le bloc symétrique dans `downloadConfiguration.dart`. Attendu : une visite téléchargée ne contient ni images d'articles ni audios. À confirmer sur un vrai device.
+> Analyse du code : le `switch` de collecte des ressources est commenté dans `ConfigurationController.Export` **et** le bloc symétrique dans `downloadConfiguration.dart`. Attendu : une visite téléchargée ne contient ni images d'articles ni audios. À confirmer sur un vrai device. **Corrigé en D1 le 2026-08-11** — ce §21 vérifie désormais la correction, pas le diagnostic.
+>
+> ✅ **Périmètre arrêté le 2026-08-13 : installation neuve, et rien d'autre.** Aucun visiteur n'a l'app installée à ce jour, et la consigne aux 4 clients est « supprimer l'app et la réinstaller ». Ça retire **deux cas** que D2 avait ouverts, et il vaut mieux savoir pourquoi avant de les remettre :
+>
+> - ⛔ **La montée v3 → v4 ne se teste pas** : `_onCreate` appelle `createTable(resources)`, qui contient déjà `dateUpdate TEXT` (`DatabaseHelper.dart:239`). Une installation neuve naît en v4 et n'emprunte jamais `_onUpgrade`. Vérifié plutôt que supposé — c'est exactement l'endroit où une colonne ajoutée en migration et oubliée à la création aurait fait planter toutes les installations neuves.
+> - ⛔ **Le cas « device portant des `.unknown` » ne se teste pas non plus** : ces fichiers datent d'avant D3, donc d'installations qui n'existent plus. Le traitement de `.unknown` par `isResourceOutdated` **reste dans le code** — il ne coûte rien et redevient utile au premier device qui garde une vieille installation. On ne teste pas un chemin de réparation pour une population de zéro ; on ne le supprime pas pour autant.
+>
+> ⚠️ **Ce qui fait tenir ce périmètre, et qui peut sauter** : la table rase n'est gratuite que tant qu'il n'y a pas de visiteur réel. Dès qu'un téléphone garde une installation — après la bascule, typiquement — les deux cas ci-dessus redeviennent la seule preuve que le parc existant est réparé.
### 21.1 — État réel du téléchargement
@@ -739,7 +746,7 @@ Points de vigilance à ne pas oublier au moment de tester :
| # | Action | Attendu | OK |
|---|--------|---------|-----|
-| 21.2.1 | Télécharger une visite, puis **remplacer une image** dans manager-app (même ressource), puis relancer le téléchargement | L'image mise à jour remplace l'ancienne sur le device | |
+| 21.2.1 | ⛔ ~~Télécharger une visite, puis **remplacer une image** dans manager-app (même ressource), puis relancer le téléchargement~~ **Cas impossible, réécrit le 2026-08-13** : `Upload` appelle `GenerateHexId()` à chaque téléversement (`ResourceController:276`), donc « remplacer une image » crée **un nouvel id et une nouvelle URL** — aucun flux ne réécrit le blob d'un id existant. Le cas d'origine testait un scénario qui ne peut pas se produire.
**À jouer à la place** : téléverser une **nouvelle** image, la placer dans une section déjà téléchargée, re-télécharger | La nouvelle image arrive sur le device ; l'ancienne, si plus référencée, est purgée (cf. 21.2.3) | |
| 21.2.2 | Relancer un téléchargement sans aucune modification | Rien n'est re-téléchargé, message « à jour » | |
| 21.2.3 | Supprimer une ressource dans manager-app puis re-télécharger | Le fichier local correspondant est purgé | |
diff --git a/v1-plan.md b/v1-plan.md
index e8905af..8b0a84d 100644
--- a/v1-plan.md
+++ b/v1-plan.md
@@ -181,6 +181,22 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales,
- `mymuseum-visitapp` : déclenchement proactif (+ M3), miroir de la conversation vocale, émission des événements vocaux. C'est désormais **tout le reste du lot F**, et le plus gros.
- Non commencé, indépendant : l'aperçu de conversation (**L9**).
+**Volet proactif + M3 du 2026-08-13 (`mymuseum-visitapp`) — ce que le code a corrigé de l'énoncé :**
+
+⚠️ **La garde ne devient pas `proactiveModeEnabled` seul, et c'est le point qui compte.** `_trigger` appelle le LLM **d'abord** et ne parle qu'ensuite via `activeVoiceOrchestrator?.ttsEngine` — le `?.` avale le cas « aucun mode vocal actif ». Avec la garde recommandée par le plan, un visiteur traversant une zone avec l'app en poche et le vocal éteint **aurait consommé des jetons Gemini facturés au quota du client pour une phrase que personne n'entend**, toutes les 30 s par point. La garde retenue est `proactiveModeEnabled && activeVoiceOrchestrator?.isRunning == true` : la vraie condition n'est pas « quel matériel », c'est « y a-t-il quelqu'un pour écouter ».
+
+⛔ **Il n'y avait pas de troisième mode à inventer : `VoiceMode.voiceOnly` existe déjà** (`voice_controller.dart:16`) et construit **le même** orchestrateur que le mode lunettes — wake word, STT, TTS Gemini, LLM. Le mode `glasses` n'ajoute que la connexion des lunettes. L'audioguide sur téléphone était donc déjà là, et `VoiceModeSheet` propose déjà les deux tuiles. **Le proactif est 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é, pas corrigé.** Ses trois usages étaient morts : la garde du proactif, et deux dans `AssistantChatSheet` — dont un (`:129`) déjà doublé par `MetaGlassesService.isConnected`, la vraie condition. La pastille « Lunettes / Déconnecté » (`:239`) n'avait **jamais été affichée à personne**, le drapeau n'étant jamais vrai ; elle s'accroche désormais à l'état réel de connexion.
+
+⚠️ **Deux pièges de format sur les points GPS, vérifiés plutôt que supposés.** `currentSections` porte des **maps JSON brutes**, pas des DTO (même idiome que `assistantSuggestions` et `ScannerDialog`), et `latitude`/`longitude` y sont des **chaînes** — c'est le type du `SectionDTO` généré. D'où `double.tryParse`. `meterZoneGPS` devient le rayon par section, la constante ne servant plus que de défaut : **c'est là que M3 se ferme**.
+
+⛔ **Un point n'est PAS livré, et il bloque l'activation du mode chez qui que ce soit.** `RecordVisitorQuestion` écrit `Question = request.Message` (`AiController:135`) — or le prompt d'un déclenchement automatique est une consigne machine (« Tu es un guide audio de musée. Le visiteur vient d'entrer dans la zone X. Accueille-le… »). Chaque passage devant une œuvre ajouterait donc **une fausse question de visiteur** dans l'onglet Guide IA, écran qui existe précisément pour montrer ce que les humains demandent. Et `HasAnswer` se déduisant des sources, un prompt d'accueil sans citation **gonflerait aussi le bloc ambre « questions sans réponse »**. C'est la famille de `WeatherSyncService` noyant le journal d'audit : le flot machine noie le signal humain dans l'écran fait pour lui. **À faire dans `manager-service` puis `mymuseum-visitapp`** : un drapeau sur `AiChatRequest`, et une journalisation qui distingue les tours machine — sans cesser de compter leurs jetons au quota, eux sont bien consommés. Le code proactif est en place et compile, mais **ne pas allumer le réglage avant ça**.
+
+✅ **Code mort supprimé le 2026-08-13 — `flutter analyze lib` rend zéro erreur, une première pour ce repo.** Trois fichiers Dart : `wake_word_service.dart`, `glasses_qr_scanner_service.dart` et `glasses_tts_service.dart` (l'ancien TTS ElevenLabs, importé par les deux seuls autres). Ils portaient les **4 erreurs** `kElevenLabsApiKey` / `kElevenLabsVoiceId` et l'APK se construisait quand même : personne ne les importait — un îlot hors du graphe de compilation, même mécanique que les 14 fichiers orphelins de `manager_api_new`. **C'étaient les ancêtres de `Services/Glasses/`.**
⚠️ **Piège d'homonymie, à ne pas re-déclencher** : `android/…/WakeWordService.kt` porte le même nom et est **bien vivant** — service natif déclaré au manifeste et piloté par `MainActivity`, c'est lui qui fait tourner le wake word. Seul le Dart est parti.
✅ **`impl/elevenlabs_tts_engine.dart` est conservé** : c'est l'implémentation de génération courante, gardée comme option si des clients jugeaient la voix Gemini insuffisante — beaucoup plus cher, donc pas un défaut.
⚠️ **Corollaire qui rétrécit le chantier du miroir vocal** : j'avais compté **cinq** `AssistantService` distincts, donc cinq historiques. Deux étaient dans ce code mort. **Il y en a trois de vivants** : le chat (`AssistantChatSheet:70`), le vocal (`MyInfoMateLlmClient:18`, `maxHistory: 6`) et le proactif (`geo_beacon_trigger_service:62`). À unifier, mais trois, pas cinq.
+
+⛔ **Le choix de voix du client n'était pas honoré — corrigé le 2026-08-13, et ce n'était pas au programme.** Le sélecteur Viva (`Sulafat`) / Marco (`Umbriel`) existe dans manager-app depuis le 07/08 et écrit `Instance.GuideVoiceId` ; `mymuseum-visitapp` ne lisait **jamais** ce champ. **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 du build passe d'`Algieba` — jamais écoutée par personne — à `Sulafat`.
⚠️ **Et aucun APK ne parlait avec la voix du produit** : `GeminiTtsEngine` n'est choisi que si `GEMINI_API_KEY` est injectée au build, ce qui n'était fait nulle part — tous les builds retombaient **en silence** sur la voix système d'Android. Le repli reste le défaut (les tests de terrain ne coûtent alors ni jetons ni argent) mais il s'annonce dans les logs, `constants.dart` porte la commande et `launch.json` a une configuration « Dev + voix Gemini ».
⚠️ **À trancher au lot J** : le STT n'est pas Google non plus — `WhisperSttEngine` pointe sur `api.openai.com` si `WHISPER_API_KEY` est définie (elle est vide aujourd'hui, c'est le device qui transcrit). L'activer ajouterait **OpenAI comme sous-traitant**, que le §8.5 des CGU ne mentionne pas.
+
**Volet `manager-app` du 2026-08-13 — deux décisions prises en écrivant, à ne pas re-trancher :**
⚠️ **Le bouton du portail ne remplace pas celui du Checkout, il prend sa place quand l'essai est fini.** L'écran n'affichait rien du tout hors essai (`if (isTrialActive)` enveloppait le seul bouton) : un client payant arrivait sur un écran sans action. C'est maintenant Checkout **pendant** l'essai, portail **après** — les deux ne coexistent jamais, souscrire deux fois n'a pas de sens.
@@ -195,7 +211,7 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales,
| ~~Insertion `VisitorQuestion`~~ · ~~purge 90 j~~ · ~~onglet « Ce que demandent vos visiteurs »~~ | ✅ **Livrés le 2026-08-11** |
| ~~Job de regroupement en thèmes~~ | **Déplacé au lot J** (J2) — il conditionne la promesse d'agrégats des CGU |
| Aperçu de conversation | **L9** — converger avec « Mode preview / cible Assistant-Persona » |
-| **Déclenchement proactif pour tous — ajouté le 2026-08-12, périmètre V1 neuf** | ✅ **Tranché : le proactif sort du domaine réservé aux lunettes.** Un téléphone qui parle à l'approche d'une œuvre, écouteurs aux oreilles, **c'est l'audioguide de musée classique** — vendable très au-delà des Ray-Ban, et sur du matériel que le visiteur a déjà.
⚠️ **Ce n'est pas « retirer une garde », c'est câbler une fonction morte.** Mesuré le 2026-08-12 : `GeoBeaconTriggerService.instance.start()` **n'est appelé nulle part**, `glassesEnabled` est déclaré `= false` et **jamais assigné**, `proactiveModeEnabled` non plus, et `geoPoints` porte le commentaire « à peupler depuis la configuration » — ce qui n'est fait nulle part. Quatre choses à brancher, pas une.
**Le travail** : (1) la garde redevient `proactiveModeEnabled` seul — **ce que la doc de la classe décrivait déjà**, le `&& glassesEnabled` n'y a jamais figuré ; (2) un appel à `start()` au bon moment du cycle de vie ; (3) `geoPoints` peuplé depuis la configuration ; (4) un réglage visiteur pour activer le mode ; (5) `VoiceSource` devient **obligatoire** et non plus confortable — voir la ligne « canal Vocal ».
✅ **Recommandation sur les écouteurs : ne pas les détecter.** Le mode est **opt-in** — le visiteur qui l'active a choisi d'être parlé. Détecter la sortie audio ajoute une dépendance pour arbitrer une situation que l'utilisateur a déjà tranchée. À revoir seulement si le test montre le contraire. | ⚠️ **Fusionne avec le bug M3**, et c'est l'économie à ne pas rater. Il existe **deux implémentations de géodéclenchement qui s'ignorent** : `GeoBeaconTriggerService` (morte, 20 m en dur pour le GPS, 3 m pour les beacons) et `configuration_page.dart:43` (vivante), dont la ligne est `int meterToBeacon = 100; // 15 meters` — **la valeur dit 100, le commentaire dit 15**. Aucune des deux ne lit `meterZoneGPS`. **Corriger M3 et brancher le proactif, c'est le même chantier** : décider laquelle des deux devient la bonne, puis lui faire lire le rayon configuré par section. Les traiter séparément, c'est corriger une constante et laisser l'autre. Même raisonnement que **L5**.
⚠️ **Nouveau périmètre V1, assumé** : ~2 à 3 jours, M3 compris. À arbitrer contre la date de bascule au même titre que K9 |
+| ~~**Déclenchement proactif pour tous**~~ | 🔨 **Livré le 2026-08-13** (garde, cycle de vie, points GPS, réglage visiteur), `flutter build apk --flavor dev` vert. **M3 fermé dans la même passe**, comme l'annonçait la colonne de droite. Détail sous le tableau. Ce qui reste du proactif est le **test sur device**, pas du code.
Énoncé d'origine : ✅ **Tranché : le proactif sort du domaine réservé aux lunettes.** Un téléphone qui parle à l'approche d'une œuvre, écouteurs aux oreilles, **c'est l'audioguide de musée classique** — vendable très au-delà des Ray-Ban, et sur du matériel que le visiteur a déjà.
⚠️ **Ce n'est pas « retirer une garde », c'est câbler une fonction morte.** Mesuré le 2026-08-12 : `GeoBeaconTriggerService.instance.start()` **n'est appelé nulle part**, `glassesEnabled` est déclaré `= false` et **jamais assigné**, `proactiveModeEnabled` non plus, et `geoPoints` porte le commentaire « à peupler depuis la configuration » — ce qui n'est fait nulle part. Quatre choses à brancher, pas une.
**Le travail** : (1) la garde redevient `proactiveModeEnabled` seul — **ce que la doc de la classe décrivait déjà**, le `&& glassesEnabled` n'y a jamais figuré ; (2) un appel à `start()` au bon moment du cycle de vie ; (3) `geoPoints` peuplé depuis la configuration ; (4) un réglage visiteur pour activer le mode ; (5) `VoiceSource` devient **obligatoire** et non plus confortable — voir la ligne « canal Vocal ».
✅ **Recommandation sur les écouteurs : ne pas les détecter.** Le mode est **opt-in** — le visiteur qui l'active a choisi d'être parlé. Détecter la sortie audio ajoute une dépendance pour arbitrer une situation que l'utilisateur a déjà tranchée. À revoir seulement si le test montre le contraire. | ⚠️ **Fusionne avec le bug M3**, et c'est l'économie à ne pas rater. Il existe **deux implémentations de géodéclenchement qui s'ignorent** : `GeoBeaconTriggerService` (morte, 20 m en dur pour le GPS, 3 m pour les beacons) et `configuration_page.dart:43` (vivante), dont la ligne est `int meterToBeacon = 100; // 15 meters` — **la valeur dit 100, le commentaire dit 15**. Aucune des deux ne lit `meterZoneGPS`. **Corriger M3 et brancher le proactif, c'est le même chantier** : décider laquelle des deux devient la bonne, puis lui faire lire le rayon configuré par section. Les traiter séparément, c'est corriger une constante et laisser l'autre. Même raisonnement que **L5**.
⚠️ **Nouveau périmètre V1, assumé** : ~2 à 3 jours, M3 compris. À arbitrer contre la date de bascule au même titre que K9 |
| **Miroir de la conversation vocale dans le chat — ajouté le 2026-08-12** | ✅ **Tranché : le chat affiche en direct ce qui se dit aux lunettes.** Ouvrir l'assistant pendant que les lunettes tournent montre 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.** Vérifié le 2026-08-12 : `MyInfoMateLlmClient` construit **son propre** `AssistantService` (`maxHistory: 6`), distinct de celui du chat écrit. Aujourd'hui, poser une question aux lunettes puis ouvrir le chat du téléphone donne un interlocuteur qui ne sait rien de ce qui vient d'être demandé. C'est **ça** le chantier ; la transcription, elle, est déjà disponible — `VoiceOrchestrator` expose `lastTranscription` et `lastTtsText` en `ValueNotifier`, personne ne les affiche.
✅ **Bénéfice de bord qui justifie de le faire proprement** : `VisitorQuestion.ConversationId` est un GUID de session, « seul lien entre deux tours ». Deux historiques = deux conversations distinctes en base pour un même visiteur qui a simplement changé de surface. **Unifier l'historique répare aussi les agrégats du Guide IA**, et donc ce que le job de thèmes du lot J regroupera.
⛔ **Écarté** : afficher passivement la dernière question/réponse dans `VoiceModeSheet` (2-3 h, zéro risque) — ça montrait que ça marche sans réparer la coupure. ~1 à 2 jours | **`mymuseum-visitapp`.** ⚠️ **Ne rien redessiner : l'UI existe déjà** — `GlassesStatusWidget` est monté en haut à droite de l'accueil (`home_3.0.dart:465`), avec état par couleur, pastille de mode actif et tap vers `VoiceModeSheet` (connexion, choix de mode, désactivation). `GlassesDebugPanel` existe aussi et **se garde** : c'est l'outil de test. Le seul écart avec « une icône tout le temps présente » est volontaire — elle se cache si `applicationInstanceDTO?.isAssistant != true` (`GlassesStatusWidget.dart:32`), donc invisible chez MDLF et le Fort, visible sur l'instance MyInfoMate. **À savoir avant de tester chez un client et de conclure à un bug.** |
| **Onglet / canal « Vocal » dans les stats** | ✅ **Maintenu en V1 — décidé le 2026-08-12**, les lunettes Ray-Ban étant ramenées en V1 (activées d'abord sur la seule instance MyInfoMate, pour observer en vrai). **Mais le chantier n'est pas où le plan le disait.**
⚠️ **L'écran est déjà fait, c'est l'émission qui manque.** `statistics_screen.dart` traite déjà `AppType.Voice` : libellé de canal (`:189`), part du vocal (`:205-210`), KPI conditionnel (`:533-541`), takeaway (`:480`). **Rien à écrire dans `manager-app`** — tout le travail est dans `mymuseum-visitapp`.
**Trois trous mesurés le 2026-08-12 :** (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 même 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 des événements — tranché : session + contenu lu à voix haute.** Une session `Voice` à l'activation des lunettes, un `sectionView` quand le guide lit réellement une section (déclenchée par QR, beacon ou question). ⛔ **Pas d'événement par question posée** : 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 est celui qui fait marcher les KPI existants et l'export PDF **sans retouche**, puisqu'il produit les mêmes types d'événements que le tactile | ⚠️ **Le canal du chat et celui des stats se décident séparément — c'est ce qui rend le chantier sûr**, et il a fallu le vérifier pour le savoir.
⛔ **Le « fallback Voice → Mobile » n'existe pas.** `guide-ia-screen-plan.md:226` l'affirme et le commentaire d'`AiController:356` le répète : **les deux sont faux.** Le code fait un `FirstOrDefault` sur le canal demandé puis `Forbid()` (`:357-361`). Envoyer `AppType.Voice` au chat sans avoir créé une `ApplicationInstance` de ce type rendrait donc **403 à chaque question** — extinction silencieuse des lunettes. C'est très probablement l'origine du `Mobile` en dur : le mur a été rencontré et contourné, le commentaire est resté sur l'intention.
✅ **Décision : le chat reste en `Mobile`.** `POST /api/Stats/event` est `[AllowAnonymous]`, ne cherche **aucune** `ApplicationInstance` et ne contrôle **aucun** canal (`StatsController:35-50`). Motif de fond, qui est aussi l'objection à retenir : les lunettes tournent **dans** l'app mobile, qui a déjà chargé son canal et ses configurations ; un canal Voice ferait jongler la même app entre deux canaux — piège déjà rencontré en K2 avec `getConfigurations` — pour zéro bénéfice de contenu.
⛔ **CORRECTION du 2026-08-12, quelques heures après la ligne ci-dessus : les `VisitEvent` ne partent PAS en `Voice` non plus. Le vocal n'est pas un canal, c'est un attribut.**
**Ce qui a fait tomber la première version** : la décision du miroir vocal (ligne suivante de ce tableau) rend explicite qu'un visiteur passe des lunettes à l'écran **dans une même session** — c'est tout son objet. Or `StatisticsService` génère **un seul `sessionId` par lancement**, et `AppTypeDistribution` compte **une entrée par session**, tranchant une session ambiguë sur son **événement le plus ancien** (`StatsController:196-203`). Donc : commencer au doigt puis mettre les lunettes ⇒ toute la session compte `Mobile`, le vocal est invisible ; commencer aux lunettes puis ouvrir l'écran ⇒ toute la session compte `Voice`, navigation tactile comprise. **Le KPI ne dit pas « la part du vocal » mais « la part des sessions dont le premier événement était vocal »** — un chiffre décidé par un accident d'ordre. La première décision supposait sans le dire qu'une session est *soit* vocale *soit* tactile.
✅ **Retenu : marquer les événements consommés à la voix dans `VisitEvent.Metadata`** — colonne JSON qui **existe déjà** et que `GetSummary` exploite déjà pour six agrégats, donc **aucune migration et le gel du lot B tient**. Le KPI devient « part des sessions ayant utilisé le vocal », vrai et calculable.
**C'est la conception que le code applique déjà ailleurs** : `VisitorQuestion` n'a pas de canal vocal, il a un booléen `IsVoice` **à côté** de son `AppType` — le vocal y est un mode d'interaction, pas une plateforme. `AppType.Voice` garde alors son sens d'origine : un déploiement lunettes **autonome** avec sa propre `ApplicationInstance`, cas pour lequel l'enum a été créé et qui n'est pas celui-ci.
⚠️ **Ce que cette correction coûte, et que la première version niait** : « zéro ligne dans `manager-app` » **devient faux**. `_voiceSessions` lit `appTypeDistribution['Voice']` (`statistics_screen.dart:205-206`) et devra lire un nouvel agrégat exposé par `GetSummary`. ~3 h, pas un chantier — mais à ne pas découvrir en route.
**À faire dans la même passe** : corriger le commentaire de `MyInfoMateLlmClient:9` pour qu'il dise *pourquoi* c'est Mobile, et corriger `guide-ia-screen-plan.md:226`. Les deux propagent la même croyance fausse |
| ~~`GetSummary` agrégé en SQL~~ | ✅ **Livré le 2026-08-12.** Sessions distinctes, sommes de durée par session, durée moyenne par section, top sections, visites par jour, distributions par session, top articles et total des scans QR partent en SQL. Les six agrégats qui se lisent dans le JSON de `Metadata` (POI, agenda, quiz, jeux, menus, validité des QR) ne remontent plus que **deux colonnes**, et seulement pour leur type d'événement. **Les stats avancées ne sont plus calculées puis effacées** pour les plans qui n'y ont pas droit : les six requêtes ne partent pas. Un changement de sémantique délibéré : « la langue de la session » était prise sur le premier événement rendu par la base, dans un ordre indéfini — c'est désormais le plus ancien horodatage.
⚠️ **La branche « stats avancées » n'était couverte par aucun test** : les cas existants ne seedent pas d'`Instance`, donc `hasAdvancedStats` était toujours faux et la moitié de la méthode n'était jamais exécutée. 11 tests ajoutés, plus 4 contre un vrai Postgres — le provider InMemory évalue tout côté client et ne dit **rien** de la traduction SQL | **L13** |
@@ -427,7 +443,7 @@ A (½-1j) ──► B (2-3j) ──► G (3-4j) ──► H (1 sem.) ──► I
- **Chemin critique : A → B → G → H → I.** Tout le reste s'y greffe.
- **Le lot K s'y greffe par la fin** : K2/K3/K4 sont parallélisables avec C, D, E et F, mais **K6 (publier les apps) précède la bascule** (**L19**). ~~Et **K5 précède D0**~~ — **K5 est livré le 2026-08-12, D0 n'est plus bloqué** : l'APK `mymuseum-visitapp` existe pour les trois flavors, le test §21 sur device peut être joué. C'est aujourd'hui **le prochain geste qui demande un appareil**, et le seul qui dise si les bugs offline D2-D5 se manifestent réellement (**L11**).
-- ⚠️ **Une question à trancher avant K6, qui n'est pas technique** : le travail V1 des deux apps visiteur est committé sur des branches qui ne sont pas `master` — `Meta-Rayban-Test` pour `mymuseum-visitapp`, `AI-Assistant-test` pour `tablet-app`. Le §5bis décrit pourtant la première comme « un POC jamais mergé ». Publier des APK depuis une branche décrite comme un POC n'est pas neutre : clarifier avant K6, pas pendant.
+- ~~**Une question à trancher avant K6**~~ ✅ **Réglée le 2026-08-13, par le propriétaire du projet : `Meta-Rayban-Test` est la branche de travail à jour, pas un POC.** Le nom est trompeur — il date de la première expérimentation — mais tout le travail V1 de `mymuseum-visitapp` y vit, les lunettes n'en sont qu'une partie (et elles restent invisibles si l'instance n'a pas l'assistant). Idem pour `AI-Assistant-test` côté `tablet-app`. **On développe et on publie depuis ces branches, il n'y a rien à clarifier avant K6.** ⚠️ **Le §5bis de STATUS.md affirmait le contraire** (« un POC jamais mergé ») et c'est ce qui a fabriqué cette fausse alerte — corrigé le 2026-08-13.
- **C, D, D-bis, E, F sont parallélisables** entre eux, mais **C1 doit précéder G** (L5) et **B doit précéder G** (L2).
- **DB3 doit précéder le job de thèmes du lot F** (L16) : c'est le seul endroit du plan où le design commande le backend.
- **La boucle du graphe a disparu le 2026-08-11.** Elle venait de : I3 dépend de la rotation des secrets (dans le lot A), et H4 dépend de I3 (L12). En déplaçant la rotation en **I0**, la contrainte devient linéaire — `I0 → I3 → H4` — et il n'y a plus de cycle à contourner. **Contrepartie à ne pas rater** : H4 (test d'onboarding) exige `app.myinfomate.be` déployé, donc I3, donc I0. Concrètement, **la rotation des secrets doit être faite avant la fin du lot H**, pas au tout dernier moment. C'est le seul endroit où le report du 11/08 crée une échéance interne.