mymuseum-visitapp/lib/Services/geo_beacon_trigger_service.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

286 lines
10 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:beacon_scanner/beacon_scanner.dart';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:mymuseum_visitapp/Models/beaconSection.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/assistantService.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
import 'package:mymuseum_visitapp/constants.dart';
/// Point de déclenchement géographique pour le TTS automatique.
class GeoTriggerPoint {
final String id;
final double latitude;
final double longitude;
final double radiusMeters;
const GeoTriggerPoint({
required this.id,
required this.latitude,
required this.longitude,
this.radiusMeters = GeoBeaconTriggerService.defaultRadiusMeters,
});
}
/// Déclenche automatiquement une réponse TTS de l'assistant quand :
/// - Le visiteur entre dans le rayon d'un GeoTriggerPoint (GPS)
/// - Un beacon BLE est détecté à proximité (< [beaconProximityMeters])
///
/// Le mode proactif doit être activé dans [VisitAppContext.proactiveModeEnabled]
/// pour que les déclenchements soient actifs.
class GeoBeaconTriggerService {
GeoBeaconTriggerService._();
static final GeoBeaconTriggerService instance = GeoBeaconTriggerService._();
static const double beaconProximityMeters = 3.0;
/// Rayon d'une zone GPS qui n'a pas de `meterZoneGPS` configuré.
static const double defaultRadiusMeters = 20.0;
static const int cooldownMillis = 30000; // 30s entre deux triggers du même point
VisitAppContext? _visitAppContext;
AssistantService? _assistantService;
StreamSubscription<Position>? _geoSub;
StreamSubscription<ScanResult>? _beaconSub;
final List<GeoTriggerPoint> _geoPoints = [];
final Set<String> _triggeredIds = {};
final Map<String, int> _lastTriggerTime = {};
bool _running = false;
/// Lance le service.
/// [geoPoints] : points de déclenchement GPS (à peupler depuis la configuration).
Future<void> start({
required VisitAppContext visitAppContext,
List<GeoTriggerPoint> geoPoints = const [],
}) async {
if (_running) return;
if (!Platform.isAndroid && !Platform.isIOS) return;
_visitAppContext = visitAppContext;
// Conversation partagée : ce que le guide dit à l'approche d'une œuvre apparaît
// dans le chat, et la question suivante du visiteur en tient compte.
_assistantService = visitAppContext.assistant;
_geoPoints
..clear()
..addAll(geoPoints);
_running = true;
await _startGeoTracking();
await _startBeaconTracking();
debugPrint('[GeoBeaconTriggerService] Started — ${_geoPoints.length} geo points, beacons active');
}
Future<void> stop() async {
await _geoSub?.cancel();
await _beaconSub?.cancel();
_geoSub = null;
_beaconSub = null;
_running = false;
debugPrint('[GeoBeaconTriggerService] Stopped');
}
/// Recharge les GeoTriggerPoints (appeler après un changement de configuration).
void updateGeoPoints(List<GeoTriggerPoint> points) {
_geoPoints
..clear()
..addAll(points);
_triggeredIds.clear();
}
// ── GPS ─────────────────────────────────────────────────────────────────────
Future<void> _startGeoTracking() async {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
debugPrint('[GeoBeaconTriggerService] Location permission not granted');
return;
}
_geoSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 5,
),
).listen(_onPosition);
}
void _onPosition(Position position) {
if (!_isProactiveModeActive()) return;
for (final point in _geoPoints) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
point.latitude,
point.longitude,
);
if (dist <= point.radiusMeters && _canTrigger(point.id)) {
_trigger(
triggerId: 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.',
);
}
}
}
// ── Beacons ──────────────────────────────────────────────────────────────────
Future<void> _startBeaconTracking() async {
final ctx = _visitAppContext;
if (ctx?.beaconSections == null || ctx!.beaconSections!.isEmpty) return;
final regions = <Region>[
if (Platform.isIOS)
Region(
identifier: 'GlassesBeacon',
beaconId: IBeaconId(proximityUUID: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825'),
)
else
Region(identifier: 'GlassesBeacon'),
];
try {
_beaconSub = BeaconScanner.instance
.ranging(regions)
.listen((ScanResult result) => _onBeaconResult(result));
} catch (e) {
debugPrint('[GeoBeaconTriggerService] Beacon scan error: $e');
}
}
void _onBeaconResult(ScanResult result) {
if (!_isProactiveModeActive()) return;
final ctx = _visitAppContext;
if (ctx?.beaconSections == null) return;
for (final beacon in result.beacons) {
if (beacon.accuracy > beaconProximityMeters) continue;
final match = ctx!.beaconSections!.firstWhere(
(bs) =>
bs != null &&
bs.minorBeaconId == beacon.id.minorId &&
bs.configurationId == ctx.configuration?.id,
orElse: () => null,
);
if (match?.sectionId != null && _canTrigger(match!.sectionId!)) {
_trigger(
triggerId: 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.',
);
}
}
}
// ── Déclenchement commun ─────────────────────────────────────────────────────
bool _canTrigger(String id) {
final now = DateTime.now().millisecondsSinceEpoch;
final last = _lastTriggerTime[id] ?? 0;
return (now - last) >= cooldownMillis;
}
/// ⚠️ **`isRunning` n'est pas une précaution décorative.** `_trigger` appelle le LLM
/// d'abord et ne parle qu'ensuite, via `activeVoiceOrchestrator?.ttsEngine` — le `?.`
/// avale silencieusement le cas « aucun mode vocal actif ». Sans cette condition, un
/// visiteur qui traverse une zone avec l'app en poche et le vocal éteint **consomme des
/// jetons Gemini facturés au quota du client pour une phrase que personne n'entend**.
///
/// L'ancienne garde exigeait `glassesEnabled`, un drapeau jamais assigné : le service
/// était donc inerte. Le remplacer par `proactiveModeEnabled` seul aurait ouvert la
/// dépense ci-dessus ; le remplacer par l'orchestrateur dit la vraie condition — il y
/// a quelqu'un pour écouter, lunettes ou écouteurs, peu importe.
bool _isProactiveModeActive() {
return _visitAppContext?.proactiveModeEnabled == true &&
activeVoiceOrchestrator?.isRunning == true;
}
/// Construit les points de déclenchement GPS depuis les sections de la configuration.
///
/// ⚠️ Deux pièges du format, vérifiés plutôt que supposés : `currentSections` porte des
/// **maps JSON brutes**, pas des DTO (cf. `assistantSuggestions` et `ScannerDialog`), et
/// `latitude`/`longitude` y sont des **chaînes**, pas des nombres — c'est le type du
/// `SectionDTO` généré. D'où le `double.tryParse` plutôt qu'un cast.
///
/// `meterZoneGPS` est le rayon réglé par section dans manager-app. C'est ici que le bug
/// M3 se ferme : le rayon n'est plus une constante mais la valeur configurée, et
/// [defaultRadiusMeters] ne sert que pour une section qui n'en porte pas.
static List<GeoTriggerPoint> buildGeoPoints(List<dynamic>? sections) {
return (sections ?? [])
.whereType<Map<String, dynamic>>()
.where((s) => s['isActive'] != false)
.map((s) {
final lat = double.tryParse('${s['latitude']}');
final lng = double.tryParse('${s['longitude']}');
final id = s['id'] as String?;
if (lat == null || lng == null || id == null) return null;
return GeoTriggerPoint(
id: id,
latitude: lat,
longitude: lng,
radiusMeters: (s['meterZoneGPS'] as int?)?.toDouble() ?? defaultRadiusMeters,
);
})
.whereType<GeoTriggerPoint>()
.toList();
}
Future<void> _trigger({
required String triggerId,
required String prompt,
}) 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.
final response = await _assistantService!.chat(
message: prompt,
configurationId: _visitAppContext?.configuration?.id,
isVoice: true,
isAutoTriggered: true,
);
if (response.reply.isNotEmpty) {
final lang = _visitAppContext?.language ?? 'FR';
await activeVoiceOrchestrator?.ttsEngine.speak(
response.reply,
languageCode: _toLangCode(lang),
);
}
} catch (e) {
debugPrint('[GeoBeaconTriggerService] Trigger error for $triggerId: $e');
}
}
String _toLangCode(String lang) {
switch (lang.toUpperCase()) {
case 'FR': return 'fr-FR';
case 'NL': return 'nl-NL';
case 'EN': return 'en-US';
case 'DE': return 'de-DE';
default: return 'fr-FR';
}
}
void dispose() {
stop();
}
}