mymuseum-visitapp/lib/Services/assistantService.dart
Thomas Fransolet e967678dd2 É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>
2026-08-13 14:32:56 +02:00

228 lines
8.5 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
/// Levée quand l'instance a épuisé son quota IA du mois (HTTP 429).
///
/// Distincte d'une panne : le visiteur ne doit pas être invité à réessayer, ça
/// le ferait boucler sur un mur. L'appelant affiche un message neutre, sans
/// jamais mentionner le motif — c'est une affaire entre le client et nous.
class AssistantUnavailableException implements Exception {
const AssistantUnavailableException();
}
/// Un tour de conversation **tel qu'il s'affiche**, par opposition à l'historique
/// envoyé au modèle. Les deux ne coïncident pas : un message d'erreur se montre au
/// visiteur sans être renvoyé au guide, et le prompt d'un déclenchement proactif
/// part au guide sans jamais s'afficher.
class AssistantTurn {
final String text;
final bool isUser;
/// Le tour est passé par la voix — lunettes, mode vocal ou déclenchement
/// proactif. C'est ce qui permet au chat de montrer d'où vient chaque message.
final bool isVoice;
/// Réponse complète (cartes, navigation). Null pour un tour du visiteur ou un
/// message de service.
final AssistantResponse? response;
const AssistantTurn({
required this.text,
required this.isUser,
this.isVoice = false,
this.response,
});
}
/// Conversation du visiteur avec le guide, **une seule par session de visite**.
///
/// ⚠️ Il y avait auparavant une instance par surface — le chat écrit, le vocal et le
/// déclenchement proactif en construisaient chacun une. Poser une question aux
/// lunettes puis ouvrir le chat du téléphone donnait donc un interlocuteur qui ne
/// savait rien de ce qui venait d'être demandé, et produisait **deux lignes
/// `VisitorQuestion` sans lien** pour un même visiteur qui avait simplement changé
/// de surface. Une conversation, deux surfaces : l'instance est portée par
/// [VisitAppContext.assistant] et partagée.
class AssistantService extends ChangeNotifier {
final VisitAppContext visitAppContext;
/// Nombre maximum de messages conservés dans l'historique envoyé au backend.
///
/// ⚠️ Le vocal utilisait 6 et le chat 10. Les deux surfaces partageant désormais
/// une conversation, tronquer différemment selon le point d'entrée n'a plus de
/// sens — c'est 10 pour tout le monde. La concision des réponses vocales est
/// obtenue par `isVoice`, qui change le prompt côté serveur, pas par l'historique.
final int maxHistory;
/// Durée d'inactivité après laquelle l'historique est automatiquement vidé.
/// null = pas de vidage automatique.
final Duration? inactivityTimeout;
final List<AiChatMessage> _history = [];
final List<AssistantTurn> _turns = [];
Timer? _inactivityTimer;
/// GUID de session, seul lien entre deux tours côté serveur.
///
/// ⚠️ Personne ne l'envoyait : `AiController` retombait alors sur un
/// `Guid.NewGuid()` par appel, donc **chaque question était une conversation
/// isolée en base**, y compris deux questions d'affilée dans le même chat. Les
/// agrégats du Guide IA ne reliaient rien.
///
/// Il change avec l'historique : quand la conversation est vidée, la suivante en
/// est une autre. Sinon la visite entière d'un même visiteur n'en formerait qu'une.
String _conversationId = _newConversationId();
String get conversationId => _conversationId;
/// Les tours à afficher, du plus ancien au plus récent.
List<AssistantTurn> get turns => List.unmodifiable(_turns);
AssistantService({
required this.visitAppContext,
this.maxHistory = 10,
this.inactivityTimeout = const Duration(minutes: 5),
});
static String _newConversationId() =>
'${DateTime.now().microsecondsSinceEpoch}-${Object().hashCode}';
/// Message affiché au visiteur sans être renvoyé au guide : erreur, quota épuisé.
/// Il n'entre pas dans [_history] — le modèle n'a pas à s'excuser d'une panne au
/// tour suivant.
void addServiceMessage(String text) {
_turns.add(AssistantTurn(text: text, isUser: false));
notifyListeners();
}
Future<AssistantResponse> chat({
required String message,
String? configurationId,
bool isVoice = false,
bool isVisitorQuestion = true,
}) => chatWithAppType(
message: message,
configurationId: configurationId,
isVoice: isVoice,
isVisitorQuestion: isVisitorQuestion,
);
/// [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 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 (isVisitorQuestion) {
_turns.add(AssistantTurn(text: message, isUser: true, isVoice: isVoice));
notifyListeners();
}
final request = AiChatRequest(
message: message,
instanceId: visitAppContext.instanceId,
appType: appType,
configurationId: configurationId,
language: visitAppContext.language?.toUpperCase() ?? 'FR',
conversationId: _conversationId,
history: List.from(_history),
isVoice: isVoice,
isVisitorQuestion: isVisitorQuestion,
);
final AiChatResponse? response;
try {
response = await visitAppContext.clientAPI.aiApi!.aiChat(request);
} on ApiException catch (e) {
if (e.code == 429) throw const AssistantUnavailableException();
rethrow;
}
if (response == null) {
throw Exception('Empty response from assistant');
}
debugPrint("AI raw response: reply='${response.reply}' navigation.sectionId='${response.navigation?.sectionId}' navigation.sectionTitle='${response.navigation?.sectionTitle}' navigation.sectionType='${response.navigation?.sectionType}' cards=${response.cards?.length}");
final result = AssistantResponse(
reply: response.reply ?? '',
cards: response.cards
?.map((c) => AiCard(
title: c.title ?? '',
subtitle: c.subtitle ?? '',
icon: c.icon,
))
.toList(),
navigation: response.navigation != null
? AssistantNavigationAction(
sectionId: response.navigation!.sectionId ?? '',
sectionTitle: response.navigation!.sectionTitle ?? '',
sectionType: response.navigation!.sectionType ?? '',
imageUrl: response.navigation!.imageUrl,
)
: null,
expectsReply: response.expectsReply ?? true,
);
// ⚠️ Le prompt d'un déclenchement proactif n'entre pas dans l'historique : le
// 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 (isVisitorQuestion) {
_history.add(AiChatMessage(role: 'user', content: message));
}
_history.add(AiChatMessage(role: 'assistant', content: result.reply));
// Cap local — inutile de garder plus que maxHistory côté client
if (_history.length > maxHistory) {
_history.removeRange(0, _history.length - maxHistory);
}
_turns.add(AssistantTurn(
text: result.reply,
isUser: false,
isVoice: isVoice,
response: result,
));
notifyListeners();
return result;
}
void _resetInactivityTimer() {
if (inactivityTimeout == null) return;
_inactivityTimer?.cancel();
_inactivityTimer = Timer(inactivityTimeout!, () {
debugPrint('[AssistantService] Inactivity timeout — clearing history');
clearHistory();
});
}
/// Vide la conversation. Le `conversationId` est renouvelé : ce qui suit est une
/// autre conversation, et le serveur doit pouvoir les distinguer.
void clearHistory() {
_history.clear();
_turns.clear();
_conversationId = _newConversationId();
_inactivityTimer?.cancel();
notifyListeners();
}
@override
void dispose() {
_inactivityTimer?.cancel();
super.dispose();
}
}