7258509528
- **Backend (Cloud Functions)** :
- Mise à jour de `firebase-functions` vers la version `7.2.5`.
- Amélioration de la sécurité et de la flexibilité des clés API Gemini (support des variables d'environnement `.env` et `.env.local`).
- Optimisation de la recherche d'équipements avec une stratégie multi-passes (exacte, par tokens, puis catégorielle/fuzzy).
- Ajout de nouveaux outils pour l'IA : `check_container_availability` et `check_container_availability_batch` pour vérifier la disponibilité des flight-cases.
- Implémentation d'un post-traitement automatique suggérant des containers complets si tous leurs équipements internes sont requis par l'événement.
- Amélioration de la résilience aux erreurs 429/503 de Gemini avec une stratégie d'exponential backoff.
- **Frontend (Flutter)** :
- Mise à jour du service `AiEquipmentAssistantService` pour gérer les métadonnées détaillées des containers (rationale, items manquants/matchings, disponibilité).
- Refonte de l'interface `AiEquipmentAssistantDialog` :
- Affichage enrichi des containers dans le récapitulatif.
- Ajout de la possibilité de sélectionner/désélectionner manuellement les containers (notamment ceux marqués comme "partiels").
- Amélioration visuelle (ombres, bordures, icônes de statut de disponibilité).
- Marquage de l'assistant en mode "BETA".
- **Général** :
- Mise à jour du `.gitignore` pour inclure `functions/.env.local`.
- Correction de typos et amélioration du logging de debug dans le backend.
281 lines
9.6 KiB
Dart
281 lines
9.6 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});
|
|
}
|
|
|
|
/// 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<String> equipmentIds;
|
|
final List<String> matchingEquipmentIds;
|
|
final List<String> 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<AiEquipmentProposalItem> items;
|
|
|
|
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
|
|
final List<EventEquipment> asEventEquipment;
|
|
|
|
/// Containers (métadonnées) proposés par l'IA.
|
|
final List<AiEquipmentProposalContainer> containers;
|
|
|
|
List<String> 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<String> 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<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 [],
|
|
AiEquipmentDocument? document,
|
|
}) 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;
|
|
|
|
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() : <String>[];
|
|
|
|
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<String, dynamic>) return null;
|
|
|
|
final proposalItems = <AiEquipmentProposalItem>[];
|
|
final eventEquipmentList = <EventEquipment>[];
|
|
// 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<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,
|
|
rationale: rationale,
|
|
));
|
|
}
|
|
}
|
|
|
|
final containersMeta = <AiEquipmentProposalContainer>[];
|
|
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<String, dynamic>.from(rawContainer);
|
|
final containerId = container['containerId']?.toString().trim() ?? '';
|
|
if (containerId.isEmpty) continue;
|
|
|
|
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
|
|
final equipmentIds = <String>[];
|
|
final matching = <String>[];
|
|
final missing = <String>[];
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|