import 'dart:async'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/utils/debug_log.dart'; /// Représente un tour de conversation dans le chat. class AiAssistantChatTurn { final bool isUser; final String text; const AiAssistantChatTurn({required this.isUser, required this.text}); } /// Document à attacher pour demander à l'IA d'analyser un devis, etc. class AiEquipmentDocument { final String base64Data; final String mimeType; final String? fileName; const AiEquipmentDocument({ required this.base64Data, required this.mimeType, this.fileName, }); } /// Un item proposé par l'IA dans la liste de matériel. class AiEquipmentProposalItem { final String equipmentId; final int quantity; final String rationale; const AiEquipmentProposalItem({ required this.equipmentId, required this.quantity, required this.rationale, }); } /// Métadonnées pour un container proposé par l'IA. class AiEquipmentProposalContainer { final String containerId; final String rationale; final List equipmentIds; final List matchingEquipmentIds; final List missingEquipmentIds; final bool partial; final bool? available; final dynamic availabilityDetail; const AiEquipmentProposalContainer({ required this.containerId, required this.rationale, this.equipmentIds = const [], this.matchingEquipmentIds = const [], this.missingEquipmentIds = const [], this.partial = false, this.available, this.availabilityDetail, }); } /// Proposition complète retournée par l'IA. class AiEquipmentProposal { final String summary; final List items; /// Équipements individuels prêts à être injectés dans l'état local de l'événement. final List asEventEquipment; /// Containers (métadonnées) proposés par l'IA. final List containers; List get containerIds => containers.map((c) => c.containerId).toList(); const AiEquipmentProposal({ required this.summary, required this.items, required this.asEventEquipment, required this.containers, }); } /// Réponse complète de l'assistant IA (message + proposition optionnelle). class AiEquipmentAssistantResponse { final String assistantMessage; final AiEquipmentProposal? proposal; final List debugLogs; const AiEquipmentAssistantResponse({ required this.assistantMessage, this.proposal, this.debugLogs = const [], }); } /// Service assistant IA logisticien. /// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal]. /// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client. class AiEquipmentAssistantService { final ApiService _apiService; AiEquipmentAssistantService({ApiService? apiService}) : _apiService = apiService ?? FirebaseFunctionsApiService(); /// Envoie un message et retourne la réponse de l'assistant IA. Future generateProposal({ required DateTime startDate, required DateTime endDate, required List history, required String userMessage, String? eventTypeId, String? excludeEventId, List currentAssignedEquipment = const [], List workingProposalEquipment = const [], AiEquipmentDocument? document, }) async { final payload = { 'startDate': startDate.toIso8601String(), 'endDate': endDate.toIso8601String(), 'userMessage': userMessage.trim(), 'history': history .where((turn) => turn.text.trim().isNotEmpty) .map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()}) .toList(), 'currentEquipment': currentAssignedEquipment .map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity}) .toList(), 'workingProposal': workingProposalEquipment .map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity}) .toList(), }; if (eventTypeId != null) payload['eventTypeId'] = eventTypeId; if (excludeEventId != null) payload['excludeEventId'] = excludeEventId; if (document != null) { payload['document'] = { 'mimeType': document.mimeType, 'data': document.base64Data, if (document.fileName != null) 'fileName': document.fileName, }; } try { DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function'); final result = await _apiService.call('aiEquipmentProposal', payload); final assistantMessage = result['assistantMessage']?.toString().trim() ?? ''; final proposal = _parseProposal(result['proposal']); final rawLogs = result['debugLogs']; final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : []; DebugLog.info( '[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}', ); return AiEquipmentAssistantResponse( assistantMessage: assistantMessage.isNotEmpty ? assistantMessage : 'Je n\'ai pas pu générer de réponse.', proposal: proposal, debugLogs: debugLogs, ); } on ApiException catch (e) { DebugLog.error('[AiEquipmentAssistantService] API error', e); if (e.isUnauthorized) { throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.'); } throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}'); } catch (e) { DebugLog.error('[AiEquipmentAssistantService] Error', e); rethrow; } } AiEquipmentProposal? _parseProposal(dynamic rawProposal) { if (rawProposal == null || rawProposal is! Map) return null; final proposalItems = []; final eventEquipmentList = []; // legacy containerIds variable removed (we now use containersMeta) final rawItems = rawProposal['items']; if (rawItems is List) { for (final rawItem in rawItems) { if (rawItem is! Map) continue; final item = Map.from(rawItem); final equipmentId = item['equipmentId']?.toString().trim() ?? ''; final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1; if (equipmentId.isEmpty || quantity <= 0) continue; final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA'; proposalItems.add(AiEquipmentProposalItem( equipmentId: equipmentId, quantity: quantity, rationale: rationale, )); eventEquipmentList.add(EventEquipment( equipmentId: equipmentId, quantity: quantity, rationale: rationale, )); } } final containersMeta = []; final rawContainers = rawProposal['containers']; if (rawContainers is List) { for (final rawContainer in rawContainers) { if (rawContainer is String) { final cid = rawContainer.toString().trim(); if (cid.isNotEmpty) { containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA')); } continue; } if (rawContainer is! Map) continue; final container = Map.from(rawContainer); final containerId = container['containerId']?.toString().trim() ?? ''; if (containerId.isEmpty) continue; final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA'; final equipmentIds = []; final matching = []; final missing = []; if (container['equipmentIds'] is List) { for (final v in container['equipmentIds']) { final s = v == null ? null : v.toString().trim(); if (s != null && s.isNotEmpty) equipmentIds.add(s); } } if (container['matchingEquipmentIds'] is List) { for (final v in container['matchingEquipmentIds']) { final s = v == null ? null : v.toString().trim(); if (s != null && s.isNotEmpty) matching.add(s); } } if (container['missingEquipmentIds'] is List) { for (final v in container['missingEquipmentIds']) { final s = v == null ? null : v.toString().trim(); if (s != null && s.isNotEmpty) missing.add(s); } } final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty); final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null; final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null; containersMeta.add(AiEquipmentProposalContainer( containerId: containerId, rationale: rationale, equipmentIds: equipmentIds, matchingEquipmentIds: matching, missingEquipmentIds: missing, partial: partial, available: available, availabilityDetail: availabilityDetail, )); } } if (proposalItems.isEmpty && containersMeta.isEmpty) return null; return AiEquipmentProposal( summary: rawProposal['summary']?.toString().trim().isNotEmpty == true ? rawProposal['summary'].toString().trim() : 'Proposition matériel générée automatiquement.', items: proposalItems, asEventEquipment: eventEquipmentList, containers: containersMeta, ); } }