- Ajout d'une Cloud Function `aiEquipmentProposal` utilisant le modèle Gemini avec function calling pour suggérer du matériel et des containers. - Implémentation de plusieurs outils (tools) côté serveur pour permettre à l'IA d'interagir avec Firestore : `search_equipment`, `check_availability_batch`, `get_past_events`, `search_event_reference` et `search_containers`. - Ajout de la dépendance `@google/generative-ai` dans le backend. - Création d'un service Flutter `AiEquipmentAssistantService` pour communiquer avec la nouvelle Cloud Function. - Ajout d'une interface de dialogue `AiEquipmentAssistantDialog` permettant aux utilisateurs de discuter avec l'IA pour affiner les propositions de matériel. - Intégration de l'assistant IA dans la section de gestion du matériel des événements (`EventAssignedEquipmentSection`). - Mise à jour de `DataService` avec de nouvelles méthodes de recherche et de vérification de disponibilité optimisées pour l'assistant. - Activation du mode développement et configuration des identifiants de test dans `env.dart`. - Optimisation des paramètres de la Cloud Function (timeout de 300s et 1GiB de RAM) pour supporter les traitements IA.
179 lines
6.0 KiB
Dart
179 lines
6.0 KiB
Dart
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});
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
}
|
|
|
|
/// Proposition complète retournée par l'IA.
|
|
class AiEquipmentProposal {
|
|
final String summary;
|
|
final List<AiEquipmentProposalItem> items;
|
|
|
|
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
|
|
final List<EventEquipment> asEventEquipment;
|
|
|
|
/// IDs des containers (flight cases) proposés par l'IA.
|
|
final List<String> containerIds;
|
|
|
|
const AiEquipmentProposal({
|
|
required this.summary,
|
|
required this.items,
|
|
required this.asEventEquipment,
|
|
required this.containerIds,
|
|
});
|
|
}
|
|
|
|
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
|
|
class AiEquipmentAssistantResponse {
|
|
final String assistantMessage;
|
|
final AiEquipmentProposal? proposal;
|
|
|
|
const AiEquipmentAssistantResponse({
|
|
required this.assistantMessage,
|
|
this.proposal,
|
|
});
|
|
}
|
|
|
|
/// 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<AiEquipmentAssistantResponse> generateProposal({
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
required List<AiAssistantChatTurn> history,
|
|
required String userMessage,
|
|
String? eventTypeId,
|
|
String? excludeEventId,
|
|
List<EventEquipment> currentAssignedEquipment = const [],
|
|
List<EventEquipment> workingProposalEquipment = const [],
|
|
}) async {
|
|
final payload = <String, dynamic>{
|
|
'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;
|
|
|
|
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']);
|
|
|
|
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,
|
|
);
|
|
} 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<String, dynamic>) return null;
|
|
|
|
final proposalItems = <AiEquipmentProposalItem>[];
|
|
final eventEquipmentList = <EventEquipment>[];
|
|
final containerIds = <String>[];
|
|
|
|
final rawItems = rawProposal['items'];
|
|
if (rawItems is List) {
|
|
for (final rawItem in rawItems) {
|
|
if (rawItem is! Map) continue;
|
|
final item = Map<String, dynamic>.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));
|
|
}
|
|
}
|
|
|
|
final rawContainers = rawProposal['containers'];
|
|
if (rawContainers is List) {
|
|
for (final rawContainer in rawContainers) {
|
|
if (rawContainer is! Map) continue;
|
|
final container = Map<String, dynamic>.from(rawContainer);
|
|
final containerId = container['containerId']?.toString().trim() ?? '';
|
|
if (containerId.isNotEmpty) {
|
|
containerIds.add(containerId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (proposalItems.isEmpty && containerIds.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,
|
|
containerIds: containerIds,
|
|
);
|
|
}
|
|
}
|