Émission des événements vocaux — le vocal est un attribut, pas un canal

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-13 14:32:56 +02:00
parent 591775cd30
commit e967678dd2
3 changed files with 53 additions and 14 deletions

View File

@ -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<AssistantResponse> 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));

View File

@ -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<void> _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 é 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');

View File

@ -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<void> track(
String eventType, {
String? sectionId,
int? durationSeconds,
Map<String, dynamic>? 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 (_) {