mymuseum-visitapp/lib/Services/assistantService.dart
Thomas Fransolet 591775cd30 Une conversation, plusieurs surfaces — miroir vocal et conversationId
Le chat écrit, le vocal et le déclenchement proactif partagent désormais une
seule conversation, portée par VisitAppContext.assistant. Les trois
AssistantService séparés ont disparu : poser une question aux lunettes puis
ouvrir le chat donnait un guide qui ne savait rien de ce qu'on venait de
demander, et produisait deux lignes VisitorQuestion sans lien pour un visiteur
qui avait simplement changé de surface.

Le maxHistory du vocal (6) s'aligne sur 10 : deux surfaces qui partagent une
conversation ne peuvent pas la tronquer différemment selon le point d'entrée. La
concision vocale vient d'isVoice, qui change le prompt côté serveur.

Le vrai obstacle n'était pas l'affichage mais la forme du chat :
AssistantChatSheet gardait ses messages en List<Widget>, des bulles déjà
construites. On ne rejoue pas une conversation à partir de widgets, et un tour
vocal survenu pendant que la feuille était fermée n'aurait jamais pu y entrer. Le
service porte maintenant les tours en données et notifie ses écouteurs.

Deux listes, délibérément : celle envoyée au modèle et celle affichée ne
coïncident pas. Un message d'erreur se montre sans repartir au guide, et le
prompt d'un déclenchement proactif part au guide sans jamais s'afficher — seule
sa réponse apparaît, marquée « À voix haute ». Sans ce marquage, le visiteur
trouverait dans son chat des messages qu'il n'a jamais tapés.

conversationId est enfin envoyé, et renouvelé quand la conversation est vidée :
sinon la visite entière d'un visiteur n'en formerait qu'une.

flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:39:33 +02:00

228 lines
8.4 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 isAutoTriggered = false,
}) => chatWithAppType(
message: message,
configurationId: configurationId,
isVoice: isVoice,
isAutoTriggered: isAutoTriggered,
);
/// [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`.
Future<AssistantResponse> chatWithAppType({
required String message,
String? configurationId,
AppType appType = AppType.Mobile,
bool isVoice = false,
bool isAutoTriggered = false,
}) 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) {
_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,
isAutoTriggered: isAutoTriggered,
);
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 (!isAutoTriggered) {
_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();
}
}