From 89ab3673c44e7d9197303c8ecc6a0f77cf970ebc Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Mon, 30 Mar 2026 12:32:33 +0200 Subject: [PATCH] ### Key Changes: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **AI Equipment Proposal (`functions/aiEquipmentProposal.js`):** - Updated Gemini model to `gemini-3.1-flash-lite-preview` and updated the API key. - Increased `MAX_TOOL_ITERATIONS` from 12 to 20. - Added a new tool `list_equipment_by_category` to allow the AI to browse equipment when specific searches fail. - Enhanced the system prompt with instructions to handle typos via category exploration and authorized more creative equipment suggestions based on event descriptions. - Improved the user prompt to include more event context (name, location, notes, and options). - Set `responseMimeType: 'application/json'` in the generation config for better reliability. - Improved error logging and user-facing error messages for timeouts. **UI & Pagination (`lib/views/`):** - **ContainerFormPage**: Replaced `StreamBuilder` with a paginated list using `DataService` for equipment selection. Added a scroll controller to support infinite scrolling and updated UI colors to use the newer `withValues` API. - **EquipmentSelectionDialog**: - Increased pagination limit from 25 to 50 items. - Implemented `_checkIfMoreItemsNeeded` logic to automatically fetch more pages if filters (like hiding conflicting items) leave the view too empty. - Added a `NotificationListener` to the `ListView` to trigger pagination on scroll. - Fixed minor encoding issues in comments. --- ### Proposed Commit Message: feat: Mise à jour du modèle Gemini et optimisation de la sélection du matériel avec pagination - Mise à jour du modèle d'IA vers `gemini-3.1-flash-lite-preview` et augmentation de la limite d'itérations des outils à 20. - Ajout de l'outil `list_equipment_by_category` pour permettre à l'IA d'explorer les alternatives en cas d'échec de recherche textuelle. - Enrichissement du prompt système et du contexte envoyé à l'IA (nom, lieu, notes et options de l'événement). - Implémentation de la pagination dans `ContainerFormPage` pour la sélection d'équipements afin d'améliorer les performances. - Optimisation de `EquipmentSelectionDialog` avec chargement automatique des pages suivantes si les filtres réduisent trop la liste visible. - Passage à `withValues` pour la gestion des couleurs et amélioration de la gestion des erreurs et du logging. --- em2rp/functions/aiEquipmentProposal.js | 92 +++++++- em2rp/lib/views/container_form_page.dart | 210 +++++++++++------- .../event/equipment_selection_dialog.dart | 85 +++++-- 3 files changed, 284 insertions(+), 103 deletions(-) diff --git a/em2rp/functions/aiEquipmentProposal.js b/em2rp/functions/aiEquipmentProposal.js index d486799..46f06ee 100644 --- a/em2rp/functions/aiEquipmentProposal.js +++ b/em2rp/functions/aiEquipmentProposal.js @@ -9,9 +9,9 @@ const { GoogleGenerativeAI } = require('@google/generative-ai'); const admin = require('firebase-admin'); const logger = require('firebase-functions/logger'); -const GEMINI_MODEL = 'gemini-2.5-flash'; -const GEMINI_API_KEY = 'AIzaSyBdBdjFLma2pLenmFBlqZHArS4GVF-mclo'; -const MAX_TOOL_ITERATIONS = 12; +const GEMINI_MODEL = 'gemini-3.1-flash-lite-preview'; +const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc'; +const MAX_TOOL_ITERATIONS = 20; const PAST_EVENTS_LIMIT = 5; const SEARCH_RESULTS_LIMIT = 20; const EVENT_SEARCH_SCAN_LIMIT = 100; @@ -141,6 +141,25 @@ const AI_TOOLS = [ required: ['equipmentIds'], }, }, + { + name: 'list_equipment_by_category', + description: 'Liste le materiel d\'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d\'une faute de frappe ou pour explorer les alternatives.', + parameters: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'Nom de la categorie.', + nullable: true, + }, + subCategory: { + type: 'string', + description: 'Nom de la sous-categorie.', + nullable: true, + }, + }, + }, + }, ], }, ]; @@ -151,8 +170,10 @@ Tu dois proposer une liste de materiel et de flight cases adaptee a l evenement Regles absolues : - Tu ne dois JAMAIS ecrire en base de donnees. - Utilise search_equipment pour trouver du materiel, check_availability_batch en priorite pour verifier la disponibilite, check_availability pour un cas isole, get_past_events pour t inspirer. +- Si une recherche precise echoue, utilise list_equipment_by_category pour explorer les categories ou trouver corriger d'eventuelles fautes de frappe de l'utilisateur. - Si l utilisateur cite un evenement precis (nom/date), appelle d abord search_event_reference pour retrouver cet evenement et reutiliser son materiel ET ses flight cases. - La sous-categorie du materiel est tres importante. Prends-la en compte en priorite. +- Sois libre d'ajouter du materiel pertinent par rapport aux options ou a la description de l'evenement si cela te semble justifie. Explique tes choix dans rationale. Regles sur les flight cases (PRIORITAIRES) : - Apres avoir identifie les equipements necessaires, appelle TOUJOURS search_containers avec la liste de leurs IDs. @@ -811,7 +832,44 @@ async function toolSearchEventReference(query, dateHint) { } /** - * Exécute un tool Gemini et retourne le résultat. + * Recherche des équipements par catégorie et sous-catégorie. + */ +async function toolListEquipmentByCategory(category, subCategory) { + let queryDb = getDb().collection('equipments'); + + if (category) { + queryDb = queryDb.where('category', '==', category); + } + + if (subCategory) { + queryDb = queryDb.where('subCategory', '==', subCategory); + } + + const snapshot = await queryDb.limit(50).get(); + + const results = snapshot.docs.map((doc) => { + const data = doc.data(); + return { + id: doc.id, + name: data.name || doc.id, + category: data.category || '', + subCategory: data.subCategory || '', + brand: data.brand || null, + model: data.model || null, + status: data.status || '', + }; + }); + + return { + category: category || 'all', + subCategory: subCategory || 'all', + count: results.length, + results, + }; +} + +/** + * Exécute un tool Gemini et retourne le résultat. */ async function executeTool(toolCall, excludeEventId, sharedContext) { const { name, args } = toolCall; @@ -849,6 +907,9 @@ async function executeTool(toolCall, excludeEventId, sharedContext) { case 'search_containers': return await toolSearchContainers(args.equipmentIds, args.query || null); + case 'list_equipment_by_category': + return await toolListEquipmentByCategory(args.category || null, args.subCategory || null); + default: return { error: `Tool inconnu: ${name}` }; } @@ -867,9 +928,13 @@ async function executeTool(toolCall, excludeEventId, sharedContext) { */ function buildUserPrompt({ userMessage, + eventName, eventTypeId, startDate, endDate, + location, + notes, + eventOptions, currentEquipment, workingProposal, }) { @@ -882,14 +947,18 @@ function buildUserPrompt({ const isAutoMode = !userMessage || userMessage.trim().length === 0; const finalMessage = isAutoMode - ? 'Génère automatiquement une proposition de matériel adaptée à cet événement, basée sur les événements similaires passés.' + ? 'Génère automatiquement une proposition de matériel adaptée à cet événement, en tenant compte des options et des détails fournis, et basée sur les événements similaires passés.' : userMessage.trim(); return [ 'Contexte de l\'événement :', + `- Nom : ${eventName || 'non renseigné'}`, `- Type d'événement (ID): ${eventTypeId || 'non renseigné'}`, `- Date de début : ${startDate}`, `- Date de fin : ${endDate}`, + `- Lieu : ${location || 'non renseigné'}`, + `- Notes/Description : ${notes || 'aucune'}`, + `- Options de l'événement : ${eventOptions || 'aucune'}`, `- Matériel déjà assigné : ${currentEquipmentStr}`, `- Proposition courante à modifier : ${workingProposalStr}`, '', @@ -981,9 +1050,13 @@ function parseAiResponse(rawText) { */ async function handleAiEquipmentProposal(req, res) { const { + eventName, eventTypeId, startDate, endDate, + location, + notes, + eventOptions, userMessage, history = [], currentEquipment = [], @@ -1002,7 +1075,7 @@ async function handleAiEquipmentProposal(req, res) { systemInstruction: SYSTEM_PROMPT, tools: AI_TOOLS, toolConfig: { functionCallingConfig: { mode: 'AUTO' } }, - generationConfig: { temperature: 0.2 }, + generationConfig: { temperature: 0.2, responseMimeType: 'application/json' }, }); // Reconstruire l'historique de conversation @@ -1021,9 +1094,13 @@ async function handleAiEquipmentProposal(req, res) { const userPrompt = buildUserPrompt({ userMessage, + eventName, eventTypeId, startDate, endDate, + location, + notes, + eventOptions, currentEquipment, workingProposal, }); @@ -1151,9 +1228,10 @@ async function handleAiEquipmentProposal(req, res) { } catch (error) { logger.error('[AI] Conversation timeout/error', { message: error?.message || 'unknown', + stack: error?.stack }); res.status(200).json({ - assistantMessage: 'La generation IA a rencontre une erreur technique. Reessaie dans quelques secondes.', + assistantMessage: 'Je suis désolé, je n\'ai pas réussi à traiter votre demande en raison d\'un temps trop long ou d\'une erreur technique. Veuillez réessayer avec des requêtes plus spécifiques.', proposal: null, }); return; diff --git a/em2rp/lib/views/container_form_page.dart b/em2rp/lib/views/container_form_page.dart index 443b1f1..ada2a23 100644 --- a/em2rp/lib/views/container_form_page.dart +++ b/em2rp/lib/views/container_form_page.dart @@ -7,6 +7,8 @@ import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/id_generator.dart'; +import 'package:em2rp/services/data_service.dart'; +import 'package:em2rp/services/api_service.dart'; class ContainerFormPage extends StatefulWidget { final ContainerModel? container; @@ -635,23 +637,86 @@ class _EquipmentSelectorDialog extends StatefulWidget { class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final DataService _dataService = DataService(FirebaseFunctionsApiService()); + EquipmentCategory? _filterCategory; String _searchQuery = ''; late Set _tempSelectedIds; + final List _paginatedEquipments = []; + bool _isLoadingMore = false; + bool _hasMoreEquipments = true; + String? _lastEquipmentId; + @override void initState() { super.initState(); // Créer une copie temporaire des IDs sélectionnés _tempSelectedIds = Set.from(widget.selectedIds); + _scrollController.addListener(_onScroll); + _loadNextPage(); } @override void dispose() { _searchController.dispose(); + _scrollController.dispose(); super.dispose(); } + void _onScroll() { + if (_isLoadingMore) return; + if (_scrollController.hasClients && + _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) { + if (_hasMoreEquipments) { + _loadNextPage(); + } + } + } + + Future _loadNextPage() async { + if (_isLoadingMore || !_hasMoreEquipments) return; + setState(() => _isLoadingMore = true); + + try { + final result = await _dataService.getEquipmentsPaginated( + limit: 50, + startAfter: _lastEquipmentId, + searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, + category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null, + sortBy: 'id', + sortOrder: 'asc', + ); + + final newEquipments = (result['equipments'] as List) + .map((data) => EquipmentModel.fromMap(data as Map, data['id'] as String)) + .toList(); + + if (mounted) { + setState(() { + _paginatedEquipments.addAll(newEquipments); + _hasMoreEquipments = result['hasMore'] as bool? ?? false; + _lastEquipmentId = result['lastVisible'] as String?; + _isLoadingMore = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoadingMore = false); + } + } + } + + Future _reloadData() async { + setState(() { + _paginatedEquipments.clear(); + _lastEquipmentId = null; + _hasMoreEquipments = true; + }); + await _loadNextPage(); + } + @override Widget build(BuildContext context) { return Dialog( @@ -701,6 +766,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { setState(() { _searchQuery = ''; }); + _reloadData(); }, ) : null, @@ -709,6 +775,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { setState(() { _searchQuery = value; }); + _reloadData(); }, ), const SizedBox(height: 16), @@ -726,6 +793,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { setState(() { _filterCategory = null; }); + _reloadData(); }, selectedColor: AppColors.rouge, labelStyle: TextStyle( @@ -743,6 +811,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { setState(() { _filterCategory = selected ? category : null; }); + _reloadData(); }, selectedColor: AppColors.rouge, labelStyle: TextStyle( @@ -760,7 +829,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.rouge.withOpacity(0.1), + color: AppColors.rouge.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -778,86 +847,62 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { // Liste des équipements Expanded( - child: StreamBuilder>( - stream: widget.equipmentProvider.equipmentStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center(child: Text('Erreur: ${snapshot.error}')); - } - - var equipment = snapshot.data ?? []; - - // Filtrer par catégorie - if (_filterCategory != null) { - equipment = equipment.where((e) => e.category == _filterCategory).toList(); - } - - // Filtrer par recherche - if (_searchQuery.isNotEmpty) { - final query = _searchQuery.toLowerCase(); - equipment = equipment.where((e) { - return e.id.toLowerCase().contains(query) || - (e.brand?.toLowerCase().contains(query) ?? false) || - (e.model?.toLowerCase().contains(query) ?? false); - }).toList(); - } - - if (equipment.isEmpty) { - return const Center( - child: Text('Aucun équipement trouvé'), - ); - } - - return ListView.builder( - itemCount: equipment.length, - itemBuilder: (context, index) { - final item = equipment[index]; - final isSelected = _tempSelectedIds.contains(item.id); - - return CheckboxListTile( - value: isSelected, - onChanged: (selected) { - setState(() { - if (selected == true) { - _tempSelectedIds.add(item.id); - } else { - _tempSelectedIds.remove(item.id); - } - }); - }, - title: Text( - item.id, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (item.brand != null || item.model != null) - Text('${item.brand ?? ''} ${item.model ?? ''}'), - const SizedBox(height: 4), - Text( - _getCategoryLabel(item.category), - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade600, - ), + child: _paginatedEquipments.isEmpty && !_isLoadingMore + ? const Center(child: Text('Aucun équipement trouvé')) + : ListView.builder( + controller: _scrollController, + itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == _paginatedEquipments.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(), ), - ], - ), - secondary: Icon( - _getCategoryIcon(item.category), - color: AppColors.rouge, - ), - activeColor: AppColors.rouge, - ); - }, - ); - }, - ), + ); + } + + final item = _paginatedEquipments[index]; + final isSelected = _tempSelectedIds.contains(item.id); + + return CheckboxListTile( + value: isSelected, + onChanged: (selected) { + setState(() { + if (selected == true) { + _tempSelectedIds.add(item.id); + } else { + _tempSelectedIds.remove(item.id); + } + }); + }, + title: Text( + item.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.brand != null || item.model != null) + Text('${item.brand ?? ''} ${item.model ?? ''}'), + const SizedBox(height: 4), + Text( + _getCategoryLabel(item.category), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + secondary: Icon( + _getCategoryIcon(item.category), + color: AppColors.rouge, + ), + activeColor: AppColors.rouge, + ); + }, + ), ), // Boutons d'action @@ -945,4 +990,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { return Icons.category; } } -} \ No newline at end of file +} + + + diff --git a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart index b6dcf63..70ef7d2 100644 --- a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart +++ b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart @@ -172,7 +172,7 @@ class _EquipmentSelectionDialogState extends State { } } - /// Initialise la sélection avec le matériel déjà assigné + /// Initialise la slection avec le matriel dj assign Future _initializeAlreadyAssigned() async { final Map initialSelection = {}; @@ -250,7 +250,7 @@ class _EquipmentSelectionDialogState extends State { try { final result = await _dataService.getEquipmentsPaginated( - limit: 25, + limit: 50, startAfter: _lastEquipmentId, searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, category: _selectedCategory != null ? equipmentCategoryToString(_selectedCategory!) : null, @@ -276,8 +276,13 @@ class _EquipmentSelectionDialogState extends State { DebugLog.info('[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments'); - // Charger les quantités pour les consommables/câbles de cette page + // Charger les quantites pour les consommables/cbles de cette page await _loadAvailableQuantities(newEquipments); + + // Vrifier si on doit charger d'autres lments (ex: tout a t filtr) + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkIfMoreItemsNeeded(); + }); } } catch (e) { DebugLog.error('[EquipmentSelectionDialog] Error loading equipment page', e); @@ -295,7 +300,7 @@ class _EquipmentSelectionDialogState extends State { try { final result = await _dataService.getContainersPaginated( - limit: 25, + limit: 50, startAfter: _lastContainerId, searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, category: _selectedCategory?.name, // Filtre par catégorie d'équipements @@ -358,8 +363,12 @@ class _EquipmentSelectionDialogState extends State { DebugLog.info('[EquipmentSelectionDialog] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMoreContainers'); DebugLog.info('[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}'); - // Mettre à jour les statuts de conflit pour les nouveaux containers + // Mettre jour les statuts de conflit pour les nouveaux containers await _updateContainerConflictStatus(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkIfMoreItemsNeeded(); + }); } } catch (e) { DebugLog.error('[EquipmentSelectionDialog] Error loading container page', e); @@ -387,6 +396,40 @@ class _EquipmentSelectionDialogState extends State { } } + void _checkIfMoreItemsNeeded() { + if (!mounted || _isLoadingMore) return; + + int visibleItems = 0; + if (_displayType == SelectionType.equipment) { + visibleItems = _paginatedEquipments.where((eq) { + return _showConflictingItems || !_conflictingEquipmentIds.contains(eq.id); + }).length; + + if (visibleItems < 15 && _hasMoreEquipments) { + _loadNextEquipmentPage(); + } else if (_scrollController.hasClients && _scrollController.position.maxScrollExtent <= 0 && _hasMoreEquipments) { + _loadNextEquipmentPage(); + } + } else { + visibleItems = _paginatedContainers.where((container) { + if (!_showConflictingItems) { + if (_conflictingContainerIds.contains(container.id)) return false; + final hasConflictingChildren = container.equipmentIds.any( + (eqId) => _conflictingEquipmentIds.contains(eqId), + ); + if (hasConflictingChildren) return false; + } + return true; + }).length; + + if (visibleItems < 15 && _hasMoreContainers) { + _loadNextContainerPage(); + } else if (_scrollController.hasClients && _scrollController.position.maxScrollExtent <= 0 && _hasMoreContainers) { + _loadNextContainerPage(); + } + } + } + @override void dispose() { _searchController.dispose(); @@ -1358,12 +1401,23 @@ class _EquipmentSelectionDialogState extends State { }).toList(); } - return ListView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - children: [ - // Header - _buildSectionHeader( + return NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 300) { + if (_displayType == SelectionType.equipment && _hasMoreEquipments) { + _loadNextEquipmentPage(); + } else if (_displayType == SelectionType.container && _hasMoreContainers) { + _loadNextContainerPage(); + } + } + return false; + }, + child: ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + children: [ + // Header + _buildSectionHeader( _displayType == SelectionType.equipment ? 'Équipements' : 'Containers', _displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory, itemWidgets.length, @@ -1411,7 +1465,8 @@ class _EquipmentSelectionDialogState extends State { ), ), ), - ], + ], + ), ); }, ); @@ -1735,8 +1790,8 @@ class _EquipmentSelectionDialogState extends State { ), ), ), - ), - ); + ), + ); } /// Widget pour le sélecteur de quantité @@ -2040,7 +2095,7 @@ class _EquipmentSelectionDialogState extends State { ], ), ), - ), + ), ); }