From e967678dd2e5274c29c0329fa8dc94567680a584 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 13 Aug 2026 14:32:56 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89mission=20des=20=C3=A9v=C3=A9nements=20vo?= =?UTF-8?q?caux=20=E2=80=94=20le=20vocal=20est=20un=20attribut,=20pas=20un?= =?UTF-8?q?=20canal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatisticsService.track porte isVoice, qui pose "voice": true dans VisitEvent.Metadata. Le flux lunettes n'émettait aucun VisitEvent : une visite vocale ne produisait ni session, ni section vue, ni durée. Le sectionView vocal part après la synthèse, pas avant : un déclenchement dont le TTS échoue n'a rien fait entendre, le compter serait faux. Aucun événement par question posée — elles sont déjà dans VisitorQuestion et remontent dans l'onglet Guide IA ; les compter ici serait le même fait dans deux écrans. isAutoTriggered suit le renommage serveur en isVisitorQuestion. flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert. Co-Authored-By: Claude Opus 5 --- lib/Services/assistantService.dart | 16 +++++----- lib/Services/geo_beacon_trigger_service.dart | 33 +++++++++++++++++--- lib/Services/statisticsService.dart | 18 ++++++++++- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/lib/Services/assistantService.dart b/lib/Services/assistantService.dart index 6350c91..d8908b6 100644 --- a/lib/Services/assistantService.dart +++ b/lib/Services/assistantService.dart @@ -102,29 +102,29 @@ class AssistantService extends ChangeNotifier { required String message, String? configurationId, bool isVoice = false, - bool isAutoTriggered = false, + bool isVisitorQuestion = true, }) => chatWithAppType( message: message, configurationId: configurationId, isVoice: isVoice, - isAutoTriggered: isAutoTriggered, + isVisitorQuestion: isVisitorQuestion, ); - /// [isAutoTriggered] : le tour vient du mode proactif, pas d'une question du visiteur. - /// Le serveur compte les jetons mais ne le journalise pas dans `VisitorQuestion`. + /// [isVisitorQuestion] à false : le tour vient du mode proactif, pas d'une question + /// posée. Le serveur compte les jetons mais ne le journalise pas dans `VisitorQuestion`. Future chatWithAppType({ required String message, String? configurationId, AppType appType = AppType.Mobile, bool isVoice = false, - bool isAutoTriggered = false, + bool isVisitorQuestion = true, }) async { _resetInactivityTimer(); // Le prompt d'un déclenchement proactif est une consigne machine : il ne // s'affiche pas. La réponse, elle, s'affichera — c'est ce que le visiteur a // entendu, et c'est tout l'objet du miroir. - if (!isAutoTriggered) { + if (isVisitorQuestion) { _turns.add(AssistantTurn(text: message, isUser: true, isVoice: isVoice)); notifyListeners(); } @@ -138,7 +138,7 @@ class AssistantService extends ChangeNotifier { conversationId: _conversationId, history: List.from(_history), isVoice: isVoice, - isAutoTriggered: isAutoTriggered, + isVisitorQuestion: isVisitorQuestion, ); final AiChatResponse? response; @@ -179,7 +179,7 @@ class AssistantService extends ChangeNotifier { // modèle n'a pas à relire « Tu es un guide audio de musée… » au tour suivant. // Sa réponse, si — c'est ce que le visiteur a entendu, et ça rend « tu peux // répéter ? » compréhensible depuis le chat comme depuis la voix. - if (!isAutoTriggered) { + if (isVisitorQuestion) { _history.add(AiChatMessage(role: 'user', content: message)); } _history.add(AiChatMessage(role: 'assistant', content: result.reply)); diff --git a/lib/Services/geo_beacon_trigger_service.dart b/lib/Services/geo_beacon_trigger_service.dart index a27a287..c1d5dd4 100644 --- a/lib/Services/geo_beacon_trigger_service.dart +++ b/lib/Services/geo_beacon_trigger_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'package:beacon_scanner/beacon_scanner.dart'; +import 'package:manager_api_new/api.dart'; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart'; @@ -123,8 +124,11 @@ class GeoBeaconTriggerService { point.longitude, ); if (dist <= point.radiusMeters && _canTrigger(point.id)) { + // L'id d'un point GPS **est** l'id de la section dont il vient (`buildGeoPoints`), + // donc la consultation est attribuable. _trigger( triggerId: point.id, + sectionId: point.id, prompt: 'Tu es un guide audio de musée. ' 'Le visiteur vient d\'entrer dans la zone "${point.id}". ' 'Accueille-le et donne-lui une brève information de bienvenue en 2 phrases.', @@ -177,6 +181,7 @@ class GeoBeaconTriggerService { if (match?.sectionId != null && _canTrigger(match!.sectionId!)) { _trigger( triggerId: match.sectionId!, + sectionId: match.sectionId, prompt: 'Tu es un guide audio de musée. ' 'Le visiteur est juste devant l\'œuvre ou l\'espace lié au point d\'intérêt "${match.sectionId}". ' 'Donne une présentation courte et engageante en 2-3 phrases.', @@ -238,23 +243,26 @@ class GeoBeaconTriggerService { .toList(); } + /// [sectionId] : renseigné quand le déclencheur correspond à une section (beacon ou + /// zone GPS d'un point d'intérêt). C'est lui qui permet de compter la consultation. Future _trigger({ required String triggerId, required String prompt, + String? sectionId, }) async { _lastTriggerTime[triggerId] = DateTime.now().millisecondsSinceEpoch; debugPrint('[GeoBeaconTriggerService] Trigger: $triggerId'); try { - // ⚠️ `isAutoTriggered` n'est pas cosmétique : sans lui, ce prompt — une consigne que - // le système s'écrit à lui-même — atterrissait dans `VisitorQuestion`, donc dans - // l'onglet « Ce que demandent vos visiteurs », et gonflait le bloc des questions - // sans réponse. Les jetons, eux, restent comptés côté serveur. + // ⚠️ `isVisitorQuestion: false` n'est pas cosmétique : sans lui, ce prompt — une + // consigne que le système s'écrit à lui-même — atterrissait dans `VisitorQuestion`, + // donc dans l'onglet « Ce que demandent vos visiteurs », et gonflait le bloc des + // questions sans réponse. Les jetons, eux, restent comptés côté serveur. final response = await _assistantService!.chat( message: prompt, configurationId: _visitAppContext?.configuration?.id, isVoice: true, - isAutoTriggered: true, + isVisitorQuestion: false, ); if (response.reply.isNotEmpty) { @@ -263,6 +271,21 @@ class GeoBeaconTriggerService { response.reply, languageCode: _toLangCode(lang), ); + + // La section a réellement été lue au visiteur : c'est une consultation, au même + // titre qu'un écran ouvert au doigt. Émis **après** la synthèse, pas avant — un + // déclenchement dont le TTS échoue n'a rien fait entendre. + // + // ⛔ Aucun é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. + if (sectionId != null) { + _visitAppContext?.statisticsService?.track( + VisitEventType.sectionView, + sectionId: sectionId, + isVoice: true, + ); + } } } catch (e) { debugPrint('[GeoBeaconTriggerService] Trigger error for $triggerId: $e'); diff --git a/lib/Services/statisticsService.dart b/lib/Services/statisticsService.dart index cd4ec33..b61e1d7 100644 --- a/lib/Services/statisticsService.dart +++ b/lib/Services/statisticsService.dart @@ -26,12 +26,28 @@ class StatisticsService { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } + /// [isVoice] marque un événement **consommé à la voix** — le guide a lu la section + /// à haute voix, par déclenchement proactif ou en réponse à une question. + /// + /// ⚠️ C'est un attribut de l'événement, **pas un `appType`**. Un visiteur passe des + /// lunettes à l'écran dans une même session — c'est tout l'objet du miroir — et + /// `AppTypeDistribution` ne compte qu'une entrée par session, tranchée sur + /// l'événement le plus ancien : commencer au doigt puis mettre les lunettes aurait + /// rendu le vocal invisible, l'inverse aurait compté la navigation tactile comme + /// vocale. Le KPI serveur devient « part des sessions ayant utilisé le vocal ». + /// + /// Même conception que `VisitorQuestion`, qui porte un booléen `IsVoice` **à côté** + /// de son `AppType`. Future track( String eventType, { String? sectionId, int? durationSeconds, Map? metadata, + bool isVoice = false, }) async { + final payload = isVoice + ? {...?metadata, 'voice': true} + : metadata; try { await clientAPI.statsApi!.statsTrackEvent(VisitEventDTO( instanceId: instanceId ?? '', @@ -42,7 +58,7 @@ class StatisticsService { appType: appType, language: language, durationSeconds: durationSeconds, - metadata: metadata != null ? jsonEncode(metadata) : null, + metadata: payload != null ? jsonEncode(payload) : null, timestamp: DateTime.now(), )); } catch (_) {