Cinquante-sept chantiers clos entre le 5 et le 13 août 2026. Le lot F est clos.
+ Cinquante-neuf chantiers clos entre le 5 et le 13 août 2026. Les lots F et J sont clos, et il ne reste aucun bug ouvert.
+
+ Lot J — le volet codable est livré (thèmes, interrupteur, mention)
+ Table d'agrégats (instance, mois, thème, compteur) : c'est elle qui tient la promesse du §8.4 des CGU. Sans elle le regroupement vit dans la ligne VisitorQuestion, donc la purge du 90e jour l'emporte avec la question et le client perd tout au 91e. Elle ne porte que des compteurs — aucune donnée personnelle, ce qui est précisément ce qui l'autorise à survivre. ⚠️ Insights lit désormais les thèmes dans cette table, pas dans les questions de la fenêtre.
+ ⚠️ Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA. Des libellés régénérés à chaque passage donneraient « Horaires » en janvier et « Questions d'horaires » en février : deux lignes distinctes, et une courbe qui ne veut rien dire — alors que la table existe pour porter cet historique. Ajouter un thème reste possible, en renommer un coupe l'historique en deux.
+ ⚠️ Le plafond de 500 par passage ne perd rien, il retarde : les plus anciennes d'abord, un passage par jour, et le job tourne à 2 h quand la purge est à 3 h 30. Un avertissement part si le retard dépasse un passage. Les jetons ne sont pas décomptés du quota client — il n'a pas demandé ces appels. Un lot en échec n'est pas marqué « Autre » pour s'en débarrasser : ce serait une perte définitive maquillée en résultat.
+ Interrupteur de collecte avec UI dans l'écran Guide IA — sans elle le client ne pouvait pas exercer le refus dont il est responsable. Mention aux visiteurs accessible depuis l'assistant, là où la collecte a lieu. ⚠️ FR/NL/EN seulement, repli anglais : traduire une mention de protection des données sans relecture serait pire que la servir en anglais. dotnet test 226.
+
+
+
+ D5 — une visite incomplète ne s'annonce plus téléchargée
+ Les échecs sont collectés au lieu de disparaître dans un print("NOT SUCCESSS"), et le téléchargement rend un échec si la liste n'est pas vide. Nouvelle clé downloadIncomplete dans les 10 langues — sans quoi getFromLocale aurait rendu une chaîne vide, le piège déjà rencontré avec event.live.
+ ⚠️ Trouvé en câblant l'écran, et c'est pire que le bug d'origine : sans état d'échec, une visite incomplète restait sur « téléchargement en cours » indéfiniment. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc il n'atteignait jamais le total et l'écran ne concluait jamais — le visiteur attendait devant une barre figée. ✅ Relancer ne re-télécharge que ce qui manque.
+
+
Le lot F est clos — canal vocal des stats, et L9 qui n'était pas un chantier
Le vocal est un attribut, pas un canal, comme tranché le 12/08. StatisticsService.track porte isVoice, qui pose "voice": true dans VisitEvent.Metadata — colonne existante, donc aucune migration et le gel du lot B tient. Le serveur expose « sessions ayant utilisé le vocal » et statistics_screen lit cet agrégat au lieu d'appTypeDistribution['Voice'], qui ne se serait jamais rempli.
diff --git a/v1-plan.md b/v1-plan.md
index 6f7ab73..db0e5ae 100644
--- a/v1-plan.md
+++ b/v1-plan.md
@@ -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` ✅.
⚠️ **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.**
✅ **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 `.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**.
⚠️ **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.
⚠️ **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 `.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.
⛔ **`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`.
⚠️ **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.
**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 | É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 |
+| ~~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.
⚠️ **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.
✅ Relancer ne re-télécharge que ce qui manque : `isResourceOutdated` voit les fichiers déjà présents |
### Lot D-bis — Les trois chantiers de design (1,5 à 2 semaines)
@@ -365,12 +365,22 @@ 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 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 |
+| ~~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`.
⚠️ **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).**
⛔ **Reste `visitapp-web`** — même mention, autre repo |
| 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 |
@@ -542,3 +552,8 @@ 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.