Compare commits

..

No commits in common. "016e880a3994c50f83b9829fcdd8d059e54e8cfc" and "fd5e1d7b3c2aeccc3a74d7b34d9ef177e50a56ef" have entirely different histories.

4 changed files with 22 additions and 326 deletions

View File

@ -797,17 +797,6 @@ Pas « le SDK est en preview » — la liste est plus concrète :
- ⛔ **Correction du 2026-08-13 : « l'APK se construit sans le POC dedans » est faux, et la conclusion qu'on en tirait aussi.** Les 4 erreurs ne sont pas dans le POC vivant, elles sont dans ses **ancêtres** : `wake_word_service.dart` n'est importé par personne, et `glasses_qr_scanner_service.dart` seulement par lui — un îlot de deux fichiers hors du graphe de `main.dart`, la génération d'avant l'orchestrateur. Le POC **actuel**, lui, est bien dans l'APK : `Services/Glasses/` est importé par `VoiceController`, et `GlassesStatusWidget` est monté sur l'accueil.<br>⚠️ **Conséquence pratique, inverse de celle qui était écrite ici** : « démontrable » **ne suppose pas** de remettre les deux constantes ElevenLabs. Le chemin vivant choisit `GeminiTtsEngine` dès que `kGeminiApiKey` est renseignée (`voice_controller.dart:94-100`) et ne touche jamais `kElevenLabs*`. La bascule TTS → Gemini réclamée ci-dessus **est déjà faite dans le code** ; ce qui reste est de supprimer les deux ancêtres morts.
- ✅ ~~**Branche jamais mergée / à trancher avant K6**~~**tranché le 2026-08-13 par le propriétaire du projet : `Meta-Rayban-Test` est la branche de travail à jour, pas un POC de côté.** Le nom est trompeur, il date de la première expérimentation ; tout le travail V1 de `mymuseum-visitapp` y vit et les lunettes n'en sont qu'une partie — invisibles, d'ailleurs, si l'instance n'a pas l'assistant. On développe et on publie depuis elle. Idem `tablet-app` sur `AI-Assistant-test`. **Rien à clarifier avant K6** ; c'est ce paragraphe qui affirmait le contraire et fabriquait l'alerte.
### Latence, langues et accusés de réception — relevé le 2026-08-13
> Plan complet : **[voice-latency-plan.md](voice-latency-plan.md)**, découpé en « maintenant / après les tests / V2 ». Ce qui suit n'en garde que les constats vérifiés dans le code.
- ⛔ **L'assistant vocal ne parle réellement que FR/NL/EN/DE, et personne ne le disait.** `_toLangCode` (`voice_orchestrator.dart:399-407`) ne mappe que ces quatre langues et **renvoie `fr-FR` par défaut** ; `constants.dart:59-70` en déclare 10. Un visiteur en italien se fait répondre en français, **sans erreur ni trace**. Décidé le 2026-08-13 : la limite est assumée et documentée, le reste de l'app garde ses 10 langues. Le coût d'en rajouter une est faible et c'est vérifié — les traductions `voice.*` existent **déjà pour les 10 langues** dans `translations.dart`, Whisper prend le code générique et est multilingue, le wake word est phonétique.
- ⛔ **Et les quatre langues annoncées ne le sont pas non plus.** `_isStopCommand`, `_isRepeatCommand`, `_isQrScanCommand`, `_isPhotoCommand` (`:375-397`) cherchent des mots **français en dur** — « répète », « arrête », « prends », « regarde ». Elles avaient été écrites en français pour tester et n'ont jamais été reprises. Un néerlandophone qui dit « herhaal » n'est pas compris, et `_isQrScanCommand` matche sur `code` : « what's the **code** of this painting » déclenche un scan QR. **À corriger avant le lot H** — sinon les tests multilingues valident autre chose que ce qu'on croit.
- ⚠️ **`done.mp3` retarde la réponse de toute sa durée.** `:212` fait `await _playDoneSound()` juste avant `ttsEngine.speak()`, et `_playSound` attend `play()`, dont le future ne se résout qu'à **la fin de la lecture**. En prime c'est un doublon : la parole *est* le signal de fin.
- ⚠️ **Le time-to-first-audio est la somme de tout.** `LlmClient.chat()` retourne un future de réponse complète (pas de flux) et `GeminiTtsEngine._synthesize()` fait un `generateContent` unaire qui attend **tout** le PCM avant d'écrire le WAV. D'où le son de réflexion en boucle, qui bouche ce trou. Le remède qui ne dépend d'aucune API nouvelle : **découper la réponse en phrases** et synthétiser la première pendant que les suivantes se préparent — contenu dans `GeminiTtsEngine`, sans toucher au backend.
- 💡 **Idée retenue mais repoussée : remplacer le bip du wake word par une phrase parlée** (« Oui, je vous écoute ») dans la langue et la voix du visiteur, 2-3 variantes. Repoussée **après les tests** parce qu'elle demande de générer et valider à l'oreille ~40 fichiers (2 voix × 4 langues × 5 phrases), et qu'une partie du besoin qu'elle compense disparaît si la latence baisse. ⚠️ Règle de cohérence si on la fait : les acks ne s'activent que si le moteur runtime est **Gemini** et que la voix correspond à `guideVoiceId` — des acks en Sulafat suivis d'une réponse en voix système Android seraient pires que le bip.
- 🔭 **Piste V2 : le Live API de Gemini** (WebSocket, audio natif bidirectionnel) supprimerait les trois maillons Whisper → LLM → TTS et débloquerait le barge-in et le VAD serveur. Faisable, mais le tool calling devrait passer par un proxy WebSocket dans `manager-service` (option retenue sur le papier), le modèle de coût passe à la **session ouverte** avec des jetons audio bien plus chers, et les modèles sont **en preview**. **À ouvrir par un spike chiffré d'une journée, pas par une décision.**
### Ce que ça change côté commercial
Le POC est **démontrable**. Face à un concurrent mono-usage type Musa Guide, une démo qui tourne pèse plus qu'une ligne « bientôt » sur la landing. À condition d'assumer le kit Android prêté, et de ne pas vendre l'add-on avant la bascule TTS.

View File

@ -456,11 +456,11 @@
<section class="summary" aria-label="Chiffres clés">
<div class="stat"><span class="n n-critical">1</span><span class="k">Urgent</span></div>
<div class="stat"><span class="n n-info">1</span><span class="k">Migration v3</span></div>
<div class="stat"><span class="n n-warn">0</span><span class="k">Bugs ouverts</span></div>
<div class="stat"><span class="n n-warn">1</span><span class="k">Bugs ouverts</span></div>
<div class="stat"><span class="n">6</span><span class="k">À tester</span></div>
<div class="stat"><span class="n">22</span><span class="k">Planifié</span></div>
<div class="stat"><span class="n n-gate">8</span><span class="k">Bascule prod</span></div>
<div class="stat"><span class="n n-good">59</span><span class="k">Fait récemment</span></div>
<div class="stat"><span class="n n-good">57</span><span class="k">Fait récemment</span></div>
</section>
<div class="filters" role="group" aria-label="Filtrer par domaine">
@ -517,9 +517,16 @@
<!-- BUGS -->
<section class="col" style="--stripe: var(--warn)">
<div class="col-head"><h2>Bugs ouverts</h2><span class="count">0</span></div>
<div class="col-head"><h2>Bugs ouverts</h2><span class="count">1</span></div>
<div class="stack">
<article class="card" data-area="visitapp">
<div class="card-meta"><span class="tag">visitapp</span></div>
<h3>Échecs de téléchargement silencieux</h3>
<p>Un fichier raté fait un <code>print</code> et passe. La visite est annoncée téléchargée alors qu'elle est incomplète.</p>
<span class="src">v2/offline-visit-plan.md</span>
</article>
</div>
</section>
@ -640,13 +647,12 @@
</article>
<article class="card" data-area="manager backend" data-horizon="v1">
<div class="card-meta"><span class="tag">juridique</span><span class="flag f-warn">Code fait · relecture à faire</span></div>
<h3>RGPD — ce qui reste est juridique, plus du code</h3>
<p><strong>Tout le volet codable est livré le 13/08</strong> : table d'agrégats de thèmes, job de regroupement, interrupteur de collecte par instance, mention affichée dans l'assistant. Voir « Fait récemment ».</p>
<p><strong>Reste, et rien de tout ça ne s'écrit en Dart ou en C#</strong> : la <strong>relecture juridique du §8</strong> des CGU (rédigé côté produit, jamais validé — priorité au §8.6 sous-traitants et au §8.5 transfert hors UE), la <strong>vérification des conditions réelles de Google</strong> (l'API Gemini offre-t-elle une résidence européenne ? un DPA est-il signé ? la réponse réécrit le §8.5), et la décision sur un <strong>DPA séparé</strong> — l'article 28 exige un acte écrit, et une commune ou un musée subsidié en demandera un en annexe.</p>
<p>⚠️ <strong>Deux traductions à commander avec cette relecture.</strong> La mention aux visiteurs n'existe qu'en FR/NL/EN alors que l'app porte 10 langues : les 7 autres replient sur l'anglais, faute de traduction relue. Traduire soi-même une mention de protection des données n'est pas une coquille d'interface qu'on rattrape après.</p>
<p>⚠️ <strong>Et une question ouverte au passage</strong> : activer <code>WHISPER_API_KEY</code> ajouterait <strong>OpenAI</strong> comme sous-traitant, que le §8.5 ne mentionne pas — il ne parle que de Google. La clé est vide aujourd'hui, c'est le moteur du device qui transcrit. À trancher avant de l'activer, pas après.</p>
<p><strong>L'échéance n'est pas la bascule mais la première question d'un visiteur réel</strong> — rien n'est collecté aujourd'hui, le délai de 90 jours n'a pas commencé à courir.</p>
<div class="card-meta"><span class="tag">manager-app</span><span class="flag f-warn">Front fait · backend à faire</span></div>
<h3>RGPD &amp; conformité — lot J, dernier avant la bascule</h3>
<p><strong>Reporté en fin de backlog le 11/08.</strong> Tenable <em>parce que</em> 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.</p>
<p><strong>Déjà fait le 11/08</strong> : 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 <em>aucun sous-traitant</em> 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.</p>
<p><strong>Reste</strong> : la <strong>table d'agrégats de thèmes</strong> (les CGU promettent que les regroupements survivent à la purge, or <code>ThemeId</code> est une colonne de la ligne supprimée — au 91<sup>e</sup> jour le client perdrait tout), le job de regroupement, un <strong>interrupteur de collecte par instance</strong> (⚠️ le client est responsable de traitement mais ne peut pas refuser la collecte), la mention à afficher côté visiteur, la <strong>relecture juridique du §8</strong> et la vérification des conditions réelles de Google.</p>
<p><strong>Périmètre tranché le 12/08 : job de regroupement <em>et</em> table d'agrégats, les deux.</strong> Donc le §8.4 des CGU n'est <strong>pas</strong> à amender — c'était l'alternative. La table ne porte que des compteurs <code>(instance, mois, thème, n)</code>, aucune donnée personnelle : elle survit légitimement à la purge. ⚠️ <strong>L'échéance n'est pas la bascule mais la première question d'un visiteur réel</strong> — 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.</p>
<span class="src">v1-plan.md — lot J · cgu-myinfomate.md §8 · mention-information-visiteurs.md</span>
</article>
@ -834,23 +840,9 @@
<section class="done">
<h2>Fait récemment</h2>
<p>Cinquante-neuf chantiers clos entre le 5 et le 13 août 2026. <strong>Les lots F et J sont clos, et il ne reste aucun bug ouvert.</strong></p>
<p>Cinquante-sept chantiers clos entre le 5 et le 13 août 2026. <strong>Le lot F est clos.</strong></p>
<div class="done-grid">
<div class="done-item">
<strong>Lot J — le volet codable est livré (thèmes, interrupteur, mention)</strong>
<span><strong>Table d'agrégats</strong> <code>(instance, mois, thème, compteur)</code> : c'est elle qui tient la promesse du §8.4 des CGU. Sans elle le regroupement vit dans la ligne <code>VisitorQuestion</code>, donc la purge du 90<sup>e</sup> jour l'emporte avec la question et le client perd tout au 91<sup>e</sup>. Elle ne porte <strong>que des compteurs</strong> — aucune donnée personnelle, ce qui est précisément ce qui l'autorise à survivre. ⚠️ <code>Insights</code> lit désormais les thèmes <strong>dans cette table</strong>, pas dans les questions de la fenêtre.</span>
<span>⚠️ <strong>Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA.</strong> Des libellés régénérés à chaque passage donneraient « Horaires » en janvier et « Questions d'horaires » en février : deux lignes distinctes, et une courbe qui ne veut rien dire — alors que la table existe pour porter cet historique. Ajouter un thème reste possible, en renommer un coupe l'historique en deux.</span>
<span>⚠️ <strong>Le plafond de 500 par passage ne perd rien, il retarde</strong> : les plus anciennes d'abord, un passage par jour, et le job tourne à 2 h quand la purge est à 3 h 30. Un avertissement part si le retard dépasse un passage. <strong>Les jetons ne sont pas décomptés du quota client</strong> — il n'a pas demandé ces appels. Un lot en échec n'est <strong>pas</strong> marqué « Autre » pour s'en débarrasser : ce serait une perte définitive maquillée en résultat.</span>
<span><strong>Interrupteur de collecte</strong> avec UI dans l'écran Guide IA — sans elle le client ne pouvait pas exercer le refus dont il est responsable. <strong>Mention aux visiteurs</strong> accessible depuis l'assistant, là où la collecte a lieu. ⚠️ FR/NL/EN seulement, repli anglais : traduire une mention de protection des données sans relecture serait pire que la servir en anglais. <code>dotnet test</code> 226.</span>
</div>
<div class="done-item">
<strong>D5 — une visite incomplète ne s'annonce plus téléchargée</strong>
<span>Les échecs sont collectés au lieu de disparaître dans un <code>print("NOT SUCCESSS")</code>, et le téléchargement rend un échec si la liste n'est pas vide. Nouvelle clé <code>downloadIncomplete</code> dans les <strong>10 langues</strong> — sans quoi <code>getFromLocale</code> aurait rendu une chaîne vide, le piège déjà rencontré avec <code>event.live</code>.</span>
<span>⚠️ <strong>Trouvé en câblant l'écran, et c'est pire que le bug d'origine</strong> : sans état d'échec, une visite incomplète restait sur « téléchargement en cours » <strong>indéfiniment</strong>. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc il n'atteignait jamais le total et l'écran ne concluait jamais — le visiteur attendait devant une barre figée. ✅ Relancer ne re-télécharge que ce qui manque.</span>
</div>
<div class="done-item">
<strong>Le lot F est clos — canal vocal des stats, et L9 qui n'était pas un chantier</strong>
<span><strong>Le vocal est un attribut, pas un canal</strong>, comme tranché le 12/08. <code>StatisticsService.track</code> porte <code>isVoice</code>, qui pose <code>"voice": true</code> dans <code>VisitEvent.Metadata</code><strong>colonne existante, donc aucune migration et le gel du lot B tient</strong>. Le serveur expose « sessions ayant utilisé le vocal » et <code>statistics_screen</code> lit cet agrégat au lieu d'<code>appTypeDistribution['Voice']</code>, qui ne se serait jamais rempli.</span>

View File

@ -134,7 +134,7 @@ L'étape A du §1quater est faite (`StoragePath`/`SizeBytes` écrits à `Create`
| ~~D2~~**2026-08-12** | **Filtre de fraîcheur livré de bout en bout.** `dateUpdate` exposé dans `ResourceDTO` (**champ DTO, pas une colonne** — le gel du lot B tient), rempli par `Resource.ToDTO()` depuis le `DateUpdate` qu'estampille déjà `StampAuditableEntities`. Client `manager_api_new` **édité à la main**. Côté device : colonne `dateUpdate` sur la table locale `resources`, base **v3 → v4**, et le filtre passe de « le fichier est là » à `isResourceOutdated`. `dotnet test` **205/205** (2 tests ajoutés), `flutter build web` (manager-app) ✅, APK `dev` de `mymuseum-visitapp` ✅.<br>⚠️ **La prémisse de la carte était fausse, et ça change ce que D2 répare.** « Une image remplacée dans le CMS ne remonte jamais sur le device » : ce cas **n'existe pas**. Vérifié le 2026-08-12 — `Upload` appelle `GenerateHexId()` à chaque fois (`ResourceController:276`), manager-app téléverse vers `pictures/{instanceId}/{resourceId}` (`resources_screen:221`), donc **remplacer une image crée un nouvel id et une nouvelle URL** : le fichier est absent du device et l'ancien filtre le téléchargeait déjà. La popup d'édition, elle, ne change que le libellé. **Aucun flux ne réécrit le blob d'un id existant.**<br>**Le vrai cas de péremption, lui, existe — et c'est D3 qui l'a créé** : les MP3 déjà téléchargés le sont en `<id>.unknown`, et « le fichier est présent » les déclarait à jour. Corriger la table d'extensions ne les répare pas : rien ne les redemande. `isResourceOutdated` traite donc `.unknown` comme absent — **sans ça, D3 était inerte sur tout device déjà en service**.<br>⚠️ **La montée v4 laisse les lignes existantes à `NULL`, délibérément** : « fichier présent, date inconnue » vaut **à jour**. Backfiller à zéro aurait fait re-télécharger l'intégralité des visites de tous les visiteurs sur leur réseau mobile, au premier lancement.<br>⚠️ **Piège trouvé en écrivant : la boucle en masse de D1 écrasait la date.** `DatabaseHelper.insert` fait un **UPDATE de toute la ligne** quand l'id existe — la boucle qui enregistre la charge (héritée de D1) repassait derrière la boucle de téléchargement et remettait la date à nul. Elle réécrit désormais la date connue localement. Et pour une ressource dont le téléchargement a **échoué**, c'est l'ancienne date qui est conservée, jamais celle du serveur — sinon un fichier absent se serait déclaré à jour |
| ~~D3~~**2026-08-12** | **`audio/mpeg` ajouté** à `_getExtensionFromContentType` (`downloadConfiguration.dart:494`). `audio/mp3` est conservé à côté : il n'est pas standard, mais rien ne dit qu'aucun blob n'est servi avec. Sans ça, un MP3 tombait dans le `?? "unknown"` et se retrouvait sur disque en `<id>.unknown` |
| ~~D4~~**2026-08-12** | **Purge des fichiers réactivée**`resource.deleteSync()` (`:139`), enveloppé d'un `try/catch` comme les autres suppressions du fichier. Elle est correctement bornée : elle ne balaie que `localPath/{configurationId}/`, comparé à la charge de ressources complète que D1 a rendue exhaustive.<br>**`cleanLocalResources` (`:216`) reste désactivée — délibérément.** Deux découvertes du 2026-08-12 : la table locale `resources` n'a **pas de colonne `configurationId`** (`DatabaseHelper.dart:227-232`), elle est **globale à toutes les visites** — et le paramètre `configuration` de la fonction n'est **jamais utilisé**. L'activer aurait purgé les lignes DB de **toutes les autres visites téléchargées** à partir des ids d'une seule configuration. Et ça n'aurait rien apporté : **rien ne lit ces lignes pour le rendu**, `CachedCustomResource:118-119` et `loading_common:105-106` retrouvent le fichier en **listant le répertoire**, pas via la colonne `path`.<br>⚠️ **La prémisse du plan était fausse** : « le second chemin appelle bien `cleanLocalResources` (`:455`) » — cette ligne est **dans un bloc commenté** (`/*class DownloadConfiguration {` en `:328`, `*/` en `:461`). Il n'y a **qu'un seul chemin vivant**, et `cleanLocalResources` n'est appelée nulle part. Ce n'était donc pas le motif `Create`/`Upload` de C1/C3.<br>**Dette laissée ouverte** : ~130 lignes de classe morte (`:328-461`) et la fonction `cleanLocalResources` elle-même, jamais appelée. Nettoyage hors périmètre du lot D |
| ~~D5~~ | ✅ **2026-08-13.** Les échecs sont collectés au lieu de disparaître dans un `print("NOT SUCCESSS")`, et `download` rend `false` si la liste n'est pas vide. Message dédié au visiteur — **nouvelle clé `downloadIncomplete` dans les 10 langues**, sans quoi `getFromLocale` aurait rendu une chaîne vide.<br>⚠️ **Découvert en câblant l'écran** : sans état d'échec, une visite incomplète restait sur « téléchargement en cours » **indéfiniment**. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc il n'atteignait jamais le total et l'écran ne concluait jamais — le visiteur attendait devant une barre figée.<br>✅ Relancer ne re-télécharge que ce qui manque : `isResourceOutdated` voit les fichiers déjà présents |
| D5 | Échecs de téléchargement remontés au lieu d'un `print` — une visite incomplète ne doit pas s'annoncer téléchargée |
### Lot D-bis — Les trois chantiers de design (1,5 à 2 semaines)
@ -365,22 +365,12 @@ Ordre du §2 de STATUS.md, corrigé par **L12**.
Les CGU §8 ont été réécrites le 2026-08-11 (`cgu-myinfomate.md`) et le texte d'information visiteurs existe (`mention-information-visiteurs.md`). Ce qui reste :
**Volet codable du lot J livré le 2026-08-13** — J1, J2, J3 et J4 (côté `mymuseum-visitapp`). `dotnet test` **226**, APK `dev` ✅, `flutter build web` ✅. Migration unique `LotJ_ThemeAggregatesAndCollectionSwitch` : une colonne + une table, rien d'autre — vérifié dans le fichier généré.
⚠️ **Les thèmes sont une liste fixe de 8, pas des thèmes découverts par l'IA** — décidé le 2026-08-13. Des libellés régénérés à chaque passage produiraient « Horaires » en janvier et « Questions d'horaires » en février : deux lignes d'agrégat distinctes, et une courbe qui ne veut rien dire. Or la table existe précisément pour porter cet historique. **Ajouter un thème reste possible, en renommer un coupe l'historique en deux.**
⚠️ **Le job tourne à 2 h, la purge à 3 h 30, et l'ordre compte** : une question purgée avant d'avoir été classée ne compte dans aucun agrégat et rien ne peut la rattraper.
⚠️ **Le plafond de 500 par passage ne perd rien, il retarde** — et c'est le point qui a été discuté. Les questions sont traitées **les plus anciennes d'abord** ; avec un passage par jour, il faudrait un volume soutenu supérieur au plafond *chaque jour pendant trois mois* pour qu'une question atteigne la purge sans thème. Un avertissement part dès que le retard dépasse un passage. **Les jetons ne sont pas décomptés du quota du client** : il n'a pas demandé ces appels.
⚠️ **Un lot en échec n'est pas marqué « Autre » pour s'en débarrasser** : ses questions restent non classées et repassent en tête au tour suivant. Marquer serait une perte définitive maquillée en résultat. Et une réponse du modèle à laquelle il manque une ligne **ne décale pas les suivantes** — la relecture se fait par numéro, jamais par position ; un test le fixe.
| # | Quoi | Note |
|---|---|---|
| ~~J1~~ ✅ | **Table d'agrégats `QuestionThemeMonthly`** | `(InstanceId, mois, thème, compteur)`, index sur `(InstanceId, Month)`. **Ne porte que des compteurs** — ni texte, ni session, ni langue : aucune donnée personnelle, donc rien qui justifierait de la purger, ce qui est précisément ce qui l'autorise à survivre. ⚠️ **`Insights` lit désormais `topics` et `themes` dans cette table, pas dans les questions de la fenêtre** — les lire dans les questions faisait disparaître l'historique au 91ᵉ jour, sans erreur ni trace |
| ~~J2~~ ✅ | **`QuestionThemingService`**, quotidien à 2 h | Classement par lots de 25 en un appel — un appel par question multiplierait le coût par 25 pour le même travail. **6 tests** |
| ~~J3~~ ✅ | **`Instance.IsVisitorQuestionCollectionEnabled`**, défaut `true` | Garde dans `Chat`, exposé au DTO, et **interrupteur dans l'écran Guide IA** — sans UI le client ne pourrait pas exercer le refus dont il est responsable. Le libellé dit ce que couper coûte (l'onglet cesse de se remplir) et ce que couper ne fait pas (rien n'est effacé, les questions déjà là vivent jusqu'à leur purge) |
| 🔨 J4 | **Mention aux visiteurs** | ✅ **`mymuseum-visitapp`** : icône dans l'en-tête de l'assistant → `VisitorPrivacyNotice`, texte repris **mot pour mot** de `mention-information-visiteurs.md`.<br>⚠️ **Trois langues seulement (FR/NL/EN), repli sur l'anglais — c'est un choix.** L'app en porte dix, mais traduire une mention de protection des données sans relecture humaine serait pire que la servir en anglais : une nuance perdue sur « nous n'enregistrons pas votre adresse IP » n'est pas une coquille d'interface. **À demander avec la relecture juridique (J5).**<br>**Reste `visitapp-web`** — même mention, autre repo |
| J1 | **Table d'agrégats de thèmes** | Les CGU promettent que les regroupements survivent à la purge. Or `ThemeId` est une colonne de `VisitorQuestion` : la purge du 90ᵉ jour l'emporte avec la ligne. Sans cette table, la promesse est vide et le client perd son historique au 91ᵉ jour |
| J2 | **Job de regroupement en thèmes** | Sans lui `ThemeId` reste nul, donc J1 ne contiendrait rien. Il remplit `topics` dans `GuideInsightsDTO` — forme déjà arrêtée par l'écran |
| J3 | **Interrupteur de collecte par instance** | ⚠️ Le client est **responsable de traitement** mais n'a aujourd'hui aucun moyen de refuser la collecte : elle est inconditionnelle dès que l'assistant est actif. Drapeau sur `Instance` + garde dans `RecordVisitorQuestion` |
| J4 | **Afficher la mention aux visiteurs** | Le texte existe, il n'est branché ni dans `visitapp-web` ni dans `mymuseum-visitapp`. Un lien depuis l'assistant suffit |
| J5 | **Faire relire le §8 des CGU** | Document contractuel, rédigé côté produit et non validé juridiquement. Priorité au §8.6 (sous-traitants) et au §8.5 (transfert hors UE) |
| J6 | **Vérifier les conditions réelles de Google** | Le §8.5 dit « peut impliquer un transfert hors UE » — prudent mais vague. Savoir si l'API Gemini utilisée offre une résidence européenne, et si un DPA est signé. La réponse réécrit le §8.5 |
| J7 | **Décider si un DPA séparé est requis** | L'article 28 exige un acte écrit. Le §8 en tient partiellement lieu ; une commune ou un musée subsidié en demandera un en annexe |
@ -552,8 +542,3 @@ Reporté en V2 et confirmé ici : SectionForm, ressource 360°, AR image trackin
**Ce que ça ajoute concrètement au code V1** : le canal Vocal des stats (lot F, ligne « canal Vocal »), c'est-à-dire l'émission d'événements `Voice` dans `mymuseum-visitapp` — l'écran, lui, est déjà fait. **Rien d'autre.** Le POC lui-même existe et est démontrable.
⚠️ **Deux réserves qui survivent à cette décision.** La branche `Meta-Rayban-Test` n'est **toujours pas mergée**, ce qui rejoint la question déjà ouverte au §3 avant K6 — et elle devient 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** : ça reste vrai, et l'activation sur une seule instance interne n'y touche pas.
**Ajouté le 2026-08-13 : le finetuning de l'expérience vocale — voir `voice-latency-plan.md`.** Latence, accusés de réception parlés, langues supportées. **Ce n'est pas un lot V1** : c'est du réglage qui se décide **après** que le flux vocal ait été testé de bout en bout, et le document le dit explicitement. Deux choses en sortent tout de suite et sont les seules à traiter avant les tests :
- ⛔ **L'assistant vocal est officiellement limité à FR/NL/EN/DE — décidé le 2026-08-13.** `_toLangCode` ne mappe que ces quatre langues et **renvoie `fr-FR` par défaut** pour les six autres déclarées dans `constants.dart` : un visiteur en italien se fait déjà répondre en français, en silence. À refléter dans le CMS et la doc commerciale. Le reste de l'app garde ses 10 langues. **Ajouter une langue plus tard coûte peu** — les traductions `voice.*` existent déjà pour les 10, Whisper est multilingue, le wake word est phonétique.
- ⛔ **Et les quatre langues annoncées ne sont pas réellement supportées** : `_isStopCommand`, `_isRepeatCommand`, `_isQrScanCommand` et `_isPhotoCommand` cherchent des mots **français en dur**. Un visiteur néerlandophone qui dit « herhaal » n'est pas compris, et `_isQrScanCommand` matche sur `code` — un anglophone demandant « what's the *code* of this painting » déclenche un scan QR. **Tester le multilingue avec ces listes, c'est tester autre chose que ce qu'on croit** : à corriger avant le lot H, pas après.

View File

@ -1,270 +0,0 @@
# Assistant vocal — latence, accusés de réception et langues
**Créé le 2026-08-13.** Point d'entrée unique pour le finetuning de l'expérience vocale
(`mymuseum-visitapp`, mode lunettes et mode téléphone). Découpé en trois horizons : ce qui se
fait **maintenant**, ce qui se décide **après les tests de terrain**, et ce qui est **V2**.
⚠️ **Ce document ne remet pas en cause la priorité du plan V1.** Tout ce qui est ici est du
finetuning : le flux vocal complet doit d'abord être testé et validé de bout en bout. Le bloc
« maintenant » est inclus parce qu'il rend les tests eux-mêmes plus honnêtes — pas parce qu'il
est urgent.
---
## 0. L'idée de départ, et pourquoi elle est repoussée
Remplacer le bip de détection du wake word (`assets/sounds/wake_detected.mp3`) par une phrase
parlée dans la langue et la voix du visiteur — « Oui, je vous écoute » — avec 2 ou 3 variantes
pour éviter la répétition mécanique sur une visite entière.
**L'idée est bonne et elle est retenue** — mais pas maintenant, et la raison est chiffrable :
elle demande de générer, écouter et valider à la main de l'ordre de **40 fichiers audio**
(2 voix × 4 langues × 5 phrases). Produire ce lot *avant* d'avoir vécu le flux réel avec Viva
et Marco, c'est décider à l'aveugle. Et surtout : une partie du problème que l'ack parlé
compense — l'attente — se règle en amont, par la latence. Si le §1 fait son travail, l'ack
parlé redevient un raffinement plutôt qu'un pansement.
---
## 1. Maintenant — avant les tests, sans produire un seul fichier audio
Six chantiers, tous contenus dans `mymuseum-visitapp`, aucun nouvel asset.
> ✅ **Livré le 2026-08-13 : 1.1, 1.2, 1.4, 1.5, 1.6.** `flutter analyze lib/Services/Glasses/`**0 erreur** (5 issues restantes, toutes préexistantes et hors de ces changements). Le matching des commandes est couvert par 16 cas de test joués en vrai (`dart run`), 16/16.
> ⏸️ **1.3 (découpage du TTS par phrase) volontairement non fait** — c'est le seul chantier structurant du lot, il mérite un feu vert séparé.
### 1.1 Le son « done » bloque la réponse
`voice_orchestrator.dart:212` fait `await _playDoneSound()` **juste avant** `ttsEngine.speak()`.
Or `_playSound` attend `_soundPlayer.play()`, dont le future ne se résout qu'à **la fin de la
lecture**. Ce son retarde donc la réponse de toute sa durée, et fait doublon avec la voix qui
démarre immédiatement après : la parole *est* le signal de fin.
**Fait.** L'appel est retiré. `_playDoneSound`, la constante `_doneSound` et l'asset ne sont
plus référencés nulle part : **le helper et la constante sont supprimés plutôt que conservés
« au cas où »**, il n'existe aujourd'hui aucun chemin de réponse sans TTS. `assets/sounds/done.mp3`
reste sur disque, à supprimer si rien ne le reprend.
### 1.2 Les assets se décodent à chaud
`_playSound` appelle `setAsset` à chaque déclenchement — décodage à chaque wake word, sur le
chemin le plus sensible à la latence qui soit.
**Fait.** `_preloadSounds()` appelé une fois depuis `start()`, les deux players restent chargés
et `_playWakeSound` se contente d'un `seek(0)` + `play()`. Un drapeau `_soundsReady` rend le
pipeline silencieux — et non plantant — si les assets manquent.
⚠️ **`play()` n'est jamais awaité sur ces sons**, et c'est délibéré : dans `just_audio`, le future
de `play()` ne se résout qu'à la **fin de la lecture**. C'est exactement le piège du §1.1.
### 1.3 Découpage du TTS par phrase — le vrai gain
C'est le chantier structurant, et le seul qui change l'ordre de grandeur.
Aujourd'hui la chaîne est bloquante de bout en bout :
- `LlmClient.chat()` retourne un `Future<({String reply, bool expectsReply})>` — réponse
complète, pas de flux. Avec les tool calls (`GetSectionDetail`, RAG), c'est plusieurs secondes.
- `GeminiTtsEngine._synthesize()` fait un `generateContent` unaire, récupère **tout** le PCM
base64, écrit un WAV sur disque, puis lit.
Donc **time-to-first-audio = LLM complet + TTS complet**. C'est ce trou que le son de réflexion
bouche aujourd'hui.
**À faire** : découper `result.reply` en phrases, synthétiser la première, la jouer, et
synthétiser les suivantes en tâche de fond pendant la lecture. Le time-to-first-audio tombe à
`LLM + TTS(1 phrase)`. Contenu dans `GeminiTtsEngine`, ne touche ni l'orchestrateur ni le
backend.
⚠️ **Non vérifié** : `:streamGenerateContent` émet-il des chunks audio progressifs sur
`gemini-2.5-flash-preview-tts` ? Si oui, ce chantier devient encore meilleur. C'est un test curl
de 20 minutes — à faire, pas à supposer.
### 1.4 Les commandes ne sont reconnues qu'en français
`_isStopCommand`, `_isRepeatCommand`, `_isQrScanCommand`, `_isPhotoCommand`
(`voice_orchestrator.dart:375-397`) cherchent des mots français en dur : « répète », « arrête »,
« prends », « regarde ».
**Ce n'est pas un manque de langues, c'est un manque dans les langues déjà annoncées.** Un
visiteur néerlandophone qui dit « herhaal » n'est pas compris. Tester un flux multilingue avec
ces listes, c'est tester autre chose que ce qu'on croit.
**Et un faux positif franc** : `_isQrScanCommand` matche sur `code`. Un anglophone qui demande
« what's the *code* of this painting » déclenche un scan QR au lieu d'une réponse.
**Fait.** Quatre listes `static const` couvrant FR/NL/EN/DE, et surtout **un changement de
méthode de matching** : `_matchesAny` compare sur **mot entier** (`(^|\W)phrase($|\W)`) au lieu de
`contains`.
Ce changement de méthode a révélé deux faux positifs qui vivaient déjà là :
- ⛔ `contains('prends')` reconnaissait « je ne com**prends** pas » comme une demande de photo ;
- ⛔ `contains('encore')` reconnaissait « raconte **encore** une histoire » comme « répète », ce qui
rejouait la réponse précédente au lieu d'en produire une nouvelle. `encore` nu est retiré au
profit d'`encore une fois`. **Arbitrage assumé** : « encore ? » tout court ne déclenche plus de
replay et part au LLM, qui s'en sort. À rouvrir si les tests montrent que le mot seul est
fréquent.
Et le match nu sur `code` est supprimé — « what's the code of this painting » ne déclenche plus de
scan QR.
**Vérifié, pas supposé** : 16 cas joués via `dart run` (11 doivent matcher dans les 4 langues,
5 ne doivent pas). 16/16.
### 1.5 Escalade du son de réflexion
Le drone continu (`thinking.mp3` en `LoopMode.one`) joue même quand la réponse arrive vite — il
devient alors du bruit pur.
**À faire**, sans nouvel asset, uniquement du timing sur le fichier existant :
| Délai | Comportement |
|---|---|
| 0 → 700 ms | silence — le visiteur vient de parler, il n'attend pas encore |
| 700 ms → 3,5 s | nappe en fade-in, plus discrète qu'aujourd'hui |
| > 3,5 s | (après les tests) filler parlé, cf. §2 |
**Fait** pour les deux premiers paliers. `_thinkingDelay = 700ms`, fondu d'entrée en 6 pas de
100 ms jusqu'à `_thinkingVolume = 0.45` (soit ~7 dB sous le niveau précédent). Un drapeau
`_thinkingWanted` interrompt le fondu si la réponse arrive pendant la montée.
⚠️ **Corrigé au passage** : `stop()` n'appelait pas `_stopThinkingLoop()`. Le timer de 700 ms
pouvait donc se déclencher **après** l'arrêt de l'orchestrateur et jouer une nappe alors que plus
rien n'était en cours.
### 1.6 Acter la limite à 4 langues
`_toLangCode` (`voice_orchestrator.dart:399-407`) ne mappe que FR/NL/EN/DE et **renvoie `fr-FR`
par défaut**, alors que `constants.dart:59-70` déclare 10 langues. Un visiteur en IT ou ES se
fait déjà répondre en français aujourd'hui, silencieusement.
**Décidé le 2026-08-13 : l'assistant vocal est officiellement à 4 langues — FR, NL, EN, DE.**
À refléter dans le CMS et la doc commerciale. Le reste de l'app garde ses 10 langues, seul le
canal vocal est restreint.
**Le coût d'en ajouter une plus tard est faible, et c'est vérifié** :
- les traductions `voice.*` existent **déjà pour les 10 langues** dans `translations.dart` ;
- Whisper prend le code générique (`fr-FR``fr`) et est multilingue ;
- Gemini TTS couvre largement IT/ES/PL ;
- le wake word est phonétique, indépendant de la langue parlée ensuite.
Reste donc, par langue ajoutée : une entrée dans `_toLangCode`, les listes de mots-clés du §1.4,
et une vérification du prompt côté backend. Pas de chantier caché.
**Fait** côté code : `_toLangCode` porte désormais le commentaire qui dit la limite et ce
qu'ajouter une langue implique — les deux endroits, pas seulement le mapping. La décision est
répercutée dans `v1-plan.md` §5 et `STATUS.md` §5bis. **Reste à faire hors code** : la refléter
dans le CMS et la doc commerciale.
---
## 2. Après les tests — décisions qui demandent d'avoir entendu le flux
Rien ici ne se tranche sur intuition. Toutes ces décisions supposent d'avoir vécu une visite
complète avec Viva **et** avec Marco, sur lunettes **et** sur téléphone.
**a. Ack parlé ou earcon ?** Et si parlé, combien de variantes. Le point de comparaison n'existe
qu'après avoir mesuré la latence post-§1.3.
**b. Génération du lot audio** — uniquement si (a) est positif.
Arborescence : `assets/sounds/ack/{viva|marco}/{fr,nl,en,de}/ack_{1..3}.mp3`, plus
`filler_{1,2}`. ~40 fichiers, ~1,5 Mo.
Deux pièges à ne pas redécouvrir :
- ⚠️ `pubspec.yaml:133` déclare `assets/sounds/` — **les déclarations de dossier ne sont pas
récursives en Flutter**. Chaque sous-dossier devra être listé.
- ⚠️ Générer avec **exactement le même `voicePrompt`** que le runtime (`kGeminiTtsPrompt`),
sinon le timbre et la prosodie décrochent entre l'ack et la réponse. Gemini TTS n'est pas
déterministe : prévoir plusieurs prises et un choix à l'oreille. Via un script
`tool/generate_voice_assets.dart`, joué une fois, résultat commité.
**c. Ordonnancement ack → écoute.** Aujourd'hui : bip non awaité + `Future.delayed(200ms)`. Avec
un ack parlé d'environ 800 ms, ouvrir le micro pendant la lecture ferait transcrire la voix de
l'assistant par Whisper — sur Ray-Ban, micro et haut-parleur partagent la monture, le couplage
est fort. **Plutôt que de faire de l'AEC** : démarrer le STT à `player.duration - 150ms`, calibré
à l'oreille sur le terrain. Budget avant écoute : ~400 ms → ~700 ms, acceptable parce que
pendant ces 700 ms le visiteur *sait* qu'il a été entendu.
**d. Filler parlé au-delà de 3,5 s.** Probablement inutile si §1.3 fait son travail — à décider
sur mesure.
**e. Règle de cohérence, non négociable.** Si `GEMINI_API_KEY` est absente, le moteur retombe sur
`FlutterTtsEngine` (voix système). Des acks pré-générés en Sulafat suivis d'une réponse en voix
Android seraient **pires que le bip**. Les acks vocaux ne s'activent que si le moteur runtime est
Gemini **et** que la voix de l'asset correspond à `guideVoiceId`. Sinon, earcon.
---
## 3. V2
### 3.1 Streaming LLM
C'est là qu'est le gros de la latence restante, une fois §1.3 livré. Implique du SSE côté
`manager-service` et un changement de signature de `LlmClient`. Combiné au découpage par phrase,
l'assistant parle dès la première phrase générée.
### 3.2 Live API Gemini — audio natif bidirectionnel
**Ce que c'est.** Aujourd'hui : trois maillons distincts (Whisper → LLM → Gemini TTS), trois
allers-retours, et deux conversions qui perdent l'intonation et l'hésitation du visiteur. Le Live
API les supprime : une **connexion WebSocket permanente** où l'on pousse le flux micro brut
(PCM 16 kHz) et reçoit du flux audio de réponse (PCM 24 kHz), sans jamais passer par du texte.
Trois choses que l'architecture actuelle ne peut **structurellement** pas faire :
- **le barge-in** — le visiteur coupe la parole à l'assistant, qui s'arrête net ;
- **la détection de fin de phrase côté serveur** (VAD) — fin du `timeout: 5 secondes` en dur de
`_listenForFollowUp` ;
- **une latence conversationnelle de l'ordre de la seconde**.
Les voix prébuilt sont de la même famille que celles utilisées aujourd'hui : Viva/Marco
pourraient survivre à la bascule.
**Comment on l'intégrerait.** Le point dur n'est pas l'audio, c'est le **tool calling**.
`MyInfoMateLlmClient` parle à `manager-service`, qui détient les outils (`GetSectionDetail`, RAG,
contexte de visite). Le Live API supporte le function calling, mais il faut décider qui exécute :
- **Option A — l'app se connecte directement à Gemini.** Latence minimale, mais l'app porterait
les définitions d'outils et rappellerait `manager-service` pour chacun. Et on ne met pas une
clé Gemini dans un APK distribué à des visiteurs : il faut des jetons éphémères émis par le
backend. Faisable, mais ça déplace de la logique métier dans le client — l'inverse de
l'architecture actuelle.
- **Option B — `manager-service` proxifie le WebSocket.** L'app parle au backend, le backend
parle à Gemini et exécute les outils là où ils vivent déjà. La clé reste au chaud, on garde la
main sur le prompt et les stats. Mais c'est du relais audio bidirectionnel temps réel en C#,
avec sessions, reconnexions et backpressure.
**C'est l'option B qui a du sens ici, et c'est elle qui coûte cher.**
**Faisable ?** Techniquement oui, sans obstacle bloquant. Trois réserves sérieuses :
1. ⚠️ **Le modèle de coût change de nature.** On ne paie plus à la requête mais à la session
ouverte, et les jetons audio coûtent nettement plus cher que le texte. Un musée avec 80
visiteurs simultanés = 80 sessions live. **À chiffrer avant toute décision technique** — les
tarifs ne sont pas connus ici, à vérifier et non à supposer.
2. ⚠️ **Sessions à durée limitée**, avec reprise à gérer. Sur un réseau mobile de visiteur dans
un bâtiment en pierre, c'est du vrai travail de robustesse.
3. ⛔ **Les modèles Live sont en preview.** Construire une fonction vendue à des clients sur une
API preview, après l'historique de bascule ElevenLabs → Gemini, serait imprudent.
**Verdict : piste V2 réelle, à ouvrir par un spike chiffré d'une journée** — connecter, mesurer
la latence réelle, mesurer le coût d'une session de 5 minutes — avant tout engagement.
### 3.3 Le reste
- **Barge-in / AEC** sur l'architecture actuelle, si le Live API n'est pas retenu.
- **Extension au-delà de 4 langues** (cf. §1.6 pour le coût réel).
---
## Références
- `mymuseum-visitapp/lib/Services/Glasses/voice_orchestrator.dart`
- `mymuseum-visitapp/lib/Services/Glasses/engines/impl/gemini_tts_engine.dart`
- `mymuseum-visitapp/lib/Services/voice_controller.dart``_buildTtsEngine`
- `mymuseum-visitapp/lib/constants.dart``kGeminiTtsVoice`, `kGeminiTtsPrompt`
- `v1-plan.md` §5 — les lunettes Ray-Ban sont en V1 depuis le 2026-08-12
- `DOCS/rayban-meta-integration.md`