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>
228 lines
8.5 KiB
Dart
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();
|
|
}
|
|
}
|