From 84c882ac0ba57c4e1f3740a001260afa9cc664d7 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Tue, 24 Mar 2026 12:00:30 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Int=C3=A9gration=20d'un=20assistant?= =?UTF-8?q?=20IA=20logisticien=20bas=C3=A9=20sur=20Gemini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache | 26 +- em2rp/functions/.env | 1 + em2rp/functions/aiEquipmentProposal.js | 1225 +++++++++++++++++ em2rp/functions/index.js | 25 + em2rp/functions/package-lock.json | 10 + em2rp/functions/package.json | 1 + em2rp/lib/config/env.dart | 7 +- .../ai_equipment_assistant_service.dart | 178 +++ em2rp/lib/services/data_service.dart | 241 +++- em2rp/lib/views/event_add_page.dart | 47 +- .../ai_equipment_assistant_dialog.dart | 384 ++++++ .../event_assigned_equipment_section.dart | 155 ++- 12 files changed, 2193 insertions(+), 107 deletions(-) create mode 100644 em2rp/functions/aiEquipmentProposal.js create mode 100644 em2rp/lib/services/ai_equipment_assistant_service.dart create mode 100644 em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache index 4795fc5..526324d 100644 --- a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63 assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d -version.json,1773324020831,d5cd7334d7c3a990dbff0821b9aaab39129803e306b0d96599b8adc6d4f433a6 -index.html,1773324025840,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 -flutter_service_worker.js,1773324116910,8582e401e070055f59183c207cf7a7e6a9219a50f5089a24a77d91d3ff77dcbc -flutter_bootstrap.js,1773324025827,74eaa66055c715df232ee96fc4114d5473f67717278fb4effa38d8b1b362e303 -assets/FontManifest.json,1773324113335,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/AssetManifest.json,1773324113335,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 -assets/AssetManifest.bin.json,1773324113335,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 -assets/AssetManifest.bin,1773324113335,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773324115847,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/shaders/ink_sparkle.frag,1773324113551,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 -assets/fonts/MaterialIcons-Regular.otf,1773324115852,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c -assets/NOTICES,1773324113339,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 -main.dart.js,1773324112059,bfc66ab7e817db63dee4b996af3dea0629c4c4e87ba91070c15b133ab5104848 +version.json,1773346314557,fda0011c81b6890abb52de8e160b96b7fa61bd4fbb8c45af2fbecb29d5df708d +index.html,1773346319918,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 +flutter_service_worker.js,1773346397863,2f92f9c59bdab08ddbc8274db4459302bd6134e3987b0decdb26323a257b0ab7 +assets/FontManifest.json,1773346394287,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +flutter_bootstrap.js,1773346319903,1a83667573bf9cf4a4a90e3d1631fbc55b97cebfb14c643ddf9d3468bde748ec +assets/AssetManifest.json,1773346394287,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 +assets/AssetManifest.bin.json,1773346394287,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 +assets/AssetManifest.bin,1773346394287,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773346397053,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/shaders/ink_sparkle.frag,1773346394513,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/fonts/MaterialIcons-Regular.otf,1773346397057,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c +assets/NOTICES,1773346394289,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 +main.dart.js,1773346393292,a9b20044339caf5878c0d72b7a45df204e67eab3d4c288b5964d852059c88bdd diff --git a/em2rp/functions/.env b/em2rp/functions/.env index 5af40c5..cd93c6f 100644 --- a/em2rp/functions/.env +++ b/em2rp/functions/.env @@ -7,3 +7,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a" # URL de l'application APP_URL="https://app.em2events.fr" +GEMINI_API_KEY="AIzaSyBdBdjFLma2pLenmFBlqZHArS4GVF-mclo" diff --git a/em2rp/functions/aiEquipmentProposal.js b/em2rp/functions/aiEquipmentProposal.js new file mode 100644 index 0000000..d486799 --- /dev/null +++ b/em2rp/functions/aiEquipmentProposal.js @@ -0,0 +1,1225 @@ +/** + * Cloud Function : Assistant IA Logisticien + * Utilise Gemini avec function calling côté serveur. + * Les tools accèdent directement à Firestore via Admin SDK. + * L'authentification Firebase est requise (pas de clé API côté client). + */ + +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 PAST_EVENTS_LIMIT = 5; +const SEARCH_RESULTS_LIMIT = 20; +const EVENT_SEARCH_SCAN_LIMIT = 100; +const MAX_TOOL_CALLS_PER_ITERATION = 5; +const AVAILABILITY_EVENTS_SCAN_LIMIT = 500; +const MAX_BATCH_AVAILABILITY_ITEMS = 50; + +// Initialisation lazy de db pour éviter les erreurs si Firebase n'est pas encore initialisé +const getDb = () => admin.firestore(); + +// ============================================================================ +// Déclarations des tools Gemini +// ============================================================================ + +const AI_TOOLS = [ + { + functionDeclarations: [ + { + name: 'search_equipment', + description: 'Recherche du materiel par mot-cle dans la base de données.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Texte de recherche (nom, catégorie, marque, modèle).', + }, + }, + required: ['query'], + }, + }, + { + name: 'check_availability', + description: 'Vérifie si un équipement est disponible pour une période donnée.', + parameters: { + type: 'object', + properties: { + equipmentId: { + type: 'string', + description: 'ID exact de l\'équipement à vérifier.', + }, + startDate: { + type: 'string', + description: 'Date de début ISO-8601. Exemple: 2026-03-20T08:00:00.000Z', + }, + endDate: { + type: 'string', + description: 'Date de fin ISO-8601. Exemple: 2026-03-21T23:00:00.000Z', + }, + }, + required: ['equipmentId', 'startDate', 'endDate'], + }, + }, + { + name: 'check_availability_batch', + description: 'Vérifie la disponibilité d une liste d équipements pour la meme période en un seul appel.', + parameters: { + type: 'object', + properties: { + equipmentIds: { + type: 'array', + items: { type: 'string' }, + description: 'Liste des IDs à vérifier (max 50).', + }, + startDate: { + type: 'string', + description: 'Date de début ISO-8601.', + }, + endDate: { + type: 'string', + description: 'Date de fin ISO-8601.', + }, + }, + required: ['equipmentIds', 'startDate', 'endDate'], + }, + }, + { + name: 'get_past_events', + description: `Retourne les ${PAST_EVENTS_LIMIT} événements passés les plus récents similaires, avec leur liste de matériel, pour s'en inspirer.`, + parameters: { + type: 'object', + properties: { + eventTypeId: { + type: 'string', + description: 'ID du type d\'événement. Optionnel.', + nullable: true, + }, + }, + }, + }, + { + name: 'search_event_reference', + description: 'Recherche un evenement precise par nom (et optionnellement date) pour reutiliser son materiel et ses flight cases.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Texte du nom ou extrait du nom de l evenement recherche.', + }, + dateHint: { + type: 'string', + description: 'Date cible ISO-8601 ou YYYY-MM-DD si connue.', + nullable: true, + }, + }, + required: ['query'], + }, + }, + { + name: 'search_containers', + description: 'Recherche des flight cases / containers contenant certains equipements. Toujours appeler avant de proposer du materiel individuellement afin de privilegier les flight cases.', + parameters: { + type: 'object', + properties: { + equipmentIds: { + type: 'array', + items: { type: 'string' }, + description: 'Liste des IDs d equipements pour lesquels chercher des flight cases.', + }, + query: { + type: 'string', + description: 'Recherche textuelle optionnelle dans le nom du container.', + nullable: true, + }, + }, + required: ['equipmentIds'], + }, + }, + ], + }, +]; + +const SYSTEM_PROMPT = `Tu es un expert logisticien IA pour la gestion de materiel evenementiel. +Tu dois proposer une liste de materiel et de flight cases adaptee a l evenement decrit. + +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 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. + +Regles sur les flight cases (PRIORITAIRES) : +- Apres avoir identifie les equipements necessaires, appelle TOUJOURS search_containers avec la liste de leurs IDs. +- Si un flight case contient tous les equipements necessaires, propose le flight case uniquement. Ne mets PAS ces equipements dans items. +- Si un flight case contient plus d equipements que necessaire (ex: 5 blinders dans le case mais on en veut 2), propose les equipements individuellement, pas le flight case. +- N ajoute du materiel individuel dans items que pour les equipements sans flight case couvrant exactement le besoin. +- Si un equipement est indisponible, cherche une alternative via search_equipment, verifie sa dispo, puis search_containers sur l alternative. +- Verifie la disponibilite de CHAQUE equipement ET container propose. +- Quand une proposition courante existe deja, renvoie toujours la LISTE FINALE COMPLETE. + +Reponse finale : du JSON valide strict, sans markdown, avec ce format exact : +{"assistantMessage":"...","proposal":{"summary":"...","containers":[{"containerId":"ID-EXACT","rationale":"..."}],"items":[{"equipmentId":"ID-EXACT","quantity":1,"rationale":"..."}]}} +- containers : flight cases proposes (les equipements couverts par un container ne doivent pas etre dans items) +- items : equipements individuels non couverts par un container +- Si impossible, renvoie proposal a null.`; + +// ============================================================================ +// Implémentation des tools côté serveur (Firestore Admin SDK) +// ============================================================================ + +/** + * Recherche des équipements dans Firestore par mot-clé. + */ +async function toolSearchEquipment(query) { + if (!query || query.trim().length < 2) { + return { query, count: 0, results: [] }; + } + + const normalizedQuery = query.trim().toLowerCase(); + + const snapshot = await getDb().collection('equipments').limit(200).get(); + const results = []; + + snapshot.docs.forEach((doc) => { + const data = doc.data(); + const searchableText = [ + data.name, + doc.id, + data.model, + data.brand, + data.category, + data.subCategory, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + if (searchableText.includes(normalizedQuery)) { + results.push({ + 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 || '', + availableQuantity: data.availableQuantity ?? null, + totalQuantity: data.totalQuantity ?? null, + }); + } + }); + + const limited = results.slice(0, SEARCH_RESULTS_LIMIT); + + return { + query, + count: limited.length, + results: limited, + }; +} + +/** + * Vérifie la disponibilité d'un équipement sur une période donnée. + */ +async function toolCheckAvailability(equipmentId, startDate, endDate, excludeEventId, sharedContext) { + if (!equipmentId || !startDate || !endDate) { + return { error: 'equipmentId, startDate et endDate sont requis.' }; + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return { error: 'Dates invalides.' }; + } + + const batchResult = await toolCheckAvailabilityBatch( + [equipmentId], + startDate, + endDate, + excludeEventId, + sharedContext, + ); + + if (!batchResult || !Array.isArray(batchResult.results) || batchResult.results.length === 0) { + return { + equipmentId, + available: true, + conflictCount: 0, + conflicts: [], + }; + } + + return batchResult.results[0]; +} + +async function loadAvailabilityCandidates(start, sharedContext) { + const windowKey = start.toISOString().slice(0, 10); + const contextCache = sharedContext?.availabilityCandidatesByWindow; + + if (contextCache && contextCache.has(windowKey)) { + return contextCache.get(windowKey); + } + + let candidateDocs = []; + + try { + const filteredSnapshot = await getDb().collection('events') + .where('EndDateTime', '>=', start) + .orderBy('EndDateTime', 'asc') + .limit(AVAILABILITY_EVENTS_SCAN_LIMIT) + .get(); + candidateDocs = filteredSnapshot.docs; + } catch (error) { + logger.warn('[AI] Availability optimized query failed, fallback to full scan', { + message: error?.message || 'unknown', + }); + + const fallbackSnapshot = await getDb().collection('events').get(); + candidateDocs = fallbackSnapshot.docs; + } + + if (contextCache) { + contextCache.set(windowKey, candidateDocs); + } + + return candidateDocs; +} + +function buildAvailabilityResultForEquipment({ + equipmentId, + start, + end, + candidateDocs, + excludeEventId, +}) { + const conflicts = []; + + for (const eventDoc of candidateDocs) { + if (excludeEventId && eventDoc.id === excludeEventId) { + continue; + } + + const event = eventDoc.data(); + if (isCancelledStatus(event.status)) { + continue; + } + + const eventStart = toDateSafe(event.StartDateTime); + const eventEnd = toDateSafe(event.EndDateTime); + + if (!eventStart || !eventEnd) { + continue; + } + + // Vérifier si l'équipement est assigné à cet événement + const assignedEquipment = Array.isArray(event.assignedEquipment) + ? event.assignedEquipment + : []; + const isDirectlyAssigned = assignedEquipment.some( + (eq) => String(eq.equipmentId || '') === String(equipmentId), + ); + + if (!isDirectlyAssigned) { + continue; + } + + // Vérifier le chevauchement de dates + const hasOverlap = start < eventEnd && end > eventStart; + if (!hasOverlap) { + continue; + } + + const overlapStart = new Date(Math.max(start.getTime(), eventStart.getTime())); + const overlapEnd = new Date(Math.min(end.getTime(), eventEnd.getTime())); + const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24)); + + conflicts.push({ + eventId: eventDoc.id, + eventName: event.name || event.Name || 'Événement sans nom', + overlapDays: Math.max(overlapDays, 1), + }); + } + + return { + equipmentId, + available: conflicts.length === 0, + conflictCount: conflicts.length, + conflicts, + }; +} + +/** + * Vérifie la disponibilité d'une liste d'équipements pour la même période. + */ +async function toolCheckAvailabilityBatch(equipmentIds, startDate, endDate, excludeEventId, sharedContext) { + if (!Array.isArray(equipmentIds) || equipmentIds.length === 0 || !startDate || !endDate) { + return { error: 'equipmentIds (array), startDate et endDate sont requis.' }; + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return { error: 'Dates invalides.' }; + } + + const normalizedIds = Array.from(new Set( + equipmentIds + .map((id) => String(id || '').trim()) + .filter((id) => id.length > 0), + )).slice(0, MAX_BATCH_AVAILABILITY_ITEMS); + + if (normalizedIds.length === 0) { + return { error: 'Aucun equipmentId valide.' }; + } + + const candidateDocs = await loadAvailabilityCandidates(start, sharedContext); + + const results = normalizedIds.map((equipmentId) => buildAvailabilityResultForEquipment({ + equipmentId, + start, + end, + candidateDocs, + excludeEventId, + })); + + return { + startDate, + endDate, + count: results.length, + results, + }; +} + +/** + * Retourne les N derniers événements passés similaires avec leur matériel. + */ +async function toolGetPastEvents(eventTypeId) { + const now = new Date(); + let query = getDb().collection('events').orderBy('StartDateTime', 'desc').limit(50); + + if (eventTypeId) { + query = getDb().collection('events') + .where('eventTypeId', '==', eventTypeId) + .orderBy('StartDateTime', 'desc') + .limit(50); + } + + const snapshot = await query.get(); + + const pastEvents = snapshot.docs + .map((doc) => { + const data = doc.data(); + const startDate = data.StartDateTime?.toDate + ? data.StartDateTime.toDate() + : new Date(data.StartDateTime); + return { doc, data, startDate }; + }) + .filter(({ startDate }) => !isNaN(startDate.getTime()) && startDate < now) + .slice(0, PAST_EVENTS_LIMIT) + .map(({ doc, data }) => { + const assignedEquipment = data.assignedEquipment || []; + return { + id: doc.id, + name: data.name || data.Name || 'Sans nom', + startDate: data.StartDateTime?.toDate + ? data.StartDateTime.toDate().toISOString() + : data.StartDateTime, + endDate: data.EndDateTime?.toDate + ? data.EndDateTime.toDate().toISOString() + : data.EndDateTime, + assignedEquipment: assignedEquipment.map((eq) => ({ + equipmentId: eq.equipmentId, + quantity: eq.quantity || 1, + })), + assignedEquipmentCount: assignedEquipment.length, + }; + }); + + return { + count: pastEvents.length, + events: pastEvents, + }; +} + +function toDateSafe(value) { + if (!value) return null; + if (value.toDate && typeof value.toDate === 'function') { + return value.toDate(); + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function isCancelledStatus(status) { + const normalized = (status || '').toString().trim().toUpperCase(); + return normalized === 'CANCELLED' || normalized === 'CANCELED'; +} + +function normalizeSearchText(value) { + return (value || '') + .toString() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9\s-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function parseDateHintFlexible(value) { + const directDate = toDateSafe(value); + if (directDate) { + return directDate; + } + + const normalized = normalizeSearchText(value); + if (!normalized) return null; + + const monthMap = { + janvier: 0, + fevrier: 1, + fevr: 1, + mars: 2, + avril: 3, + avr: 3, + mai: 4, + juin: 5, + juillet: 6, + juil: 6, + aout: 7, + septembre: 8, + sept: 8, + octobre: 9, + oct: 9, + novembre: 10, + nov: 10, + decembre: 11, + dec: 11, + }; + + const frDateMatch = normalized.match(/(\d{1,2})\s+([a-z]+)\s+(\d{4})/); + if (frDateMatch) { + const day = parseInt(frDateMatch[1], 10); + const monthKey = frDateMatch[2]; + const year = parseInt(frDateMatch[3], 10); + const month = monthMap[monthKey]; + + if (!Number.isNaN(day) && !Number.isNaN(year) && month !== undefined) { + const parsed = new Date(Date.UTC(year, month, day)); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + } + + return null; +} + +function dateToYmd(dateValue) { + if (!dateValue) return null; + return dateValue.toISOString().slice(0, 10); +} + +function dateToYmdInTimezone(dateValue, timeZone) { + if (!dateValue) return null; + + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + + const parts = formatter.formatToParts(dateValue); + const year = parts.find((part) => part.type === 'year')?.value; + const month = parts.find((part) => part.type === 'month')?.value; + const day = parts.find((part) => part.type === 'day')?.value; + + if (!year || !month || !day) { + return null; + } + + return `${year}-${month}-${day}`; +} + +function parseYmdToUtcDate(ymd) { + if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(ymd)) { + return null; + } + + const [year, month, day] = ymd.split('-').map((value) => parseInt(value, 10)); + return new Date(Date.UTC(year, month - 1, day)); +} + +function buildToolCacheKey(name, args) { + const safeArgs = args && typeof args === 'object' ? args : {}; + return `${name}:${JSON.stringify(safeArgs)}`; +} + +function isDateMatchingHint(eventDate, hintedYmd) { + if (!eventDate || !hintedYmd) { + return true; + } + + const eventUtcYmd = dateToYmd(eventDate); + const eventParisYmd = dateToYmdInTimezone(eventDate, 'Europe/Paris'); + + if (eventUtcYmd === hintedYmd || eventParisYmd === hintedYmd) { + return true; + } + + const hintedDate = parseYmdToUtcDate(hintedYmd); + if (!hintedDate) { + return false; + } + + const eventDateUtc = parseYmdToUtcDate(eventUtcYmd); + const eventDateParis = parseYmdToUtcDate(eventParisYmd); + const oneDayInMs = 24 * 60 * 60 * 1000; + + const utcDelta = eventDateUtc + ? Math.abs(eventDateUtc.getTime() - hintedDate.getTime()) + : Number.MAX_SAFE_INTEGER; + const parisDelta = eventDateParis + ? Math.abs(eventDateParis.getTime() - hintedDate.getTime()) + : Number.MAX_SAFE_INTEGER; + + return Math.min(utcDelta, parisDelta) <= oneDayInMs; +} + +function extractQueryTokens(rawQuery) { + const monthWords = new Set([ + 'janvier', 'fevrier', 'fevr', 'mars', 'avril', 'avr', 'mai', 'juin', + 'juillet', 'juil', 'aout', 'septembre', 'sept', 'octobre', 'oct', + 'novembre', 'nov', 'decembre', 'dec', + ]); + const stopWords = new Set([ + 'de', 'du', 'des', 'le', 'la', 'les', 'un', 'une', 'en', 'date', 'sur', + 'pour', 'avec', 'et', 'a', 'au', 'aux', + ]); + + return normalizeSearchText(rawQuery) + .split(' ') + .filter((token) => token.length >= 2) + .filter((token) => !/^\d+$/.test(token)) + .filter((token) => !monthWords.has(token)) + .filter((token) => !stopWords.has(token)); +} + +/** + * Recherche des containers/flight cases contenant des equipements donnes. + */ +async function toolSearchContainers(equipmentIds, query) { + if (!Array.isArray(equipmentIds) || equipmentIds.length === 0) { + return { error: 'equipmentIds (array) est requis.' }; + } + + const normalizedEquipmentIds = new Set( + equipmentIds.map((id) => String(id || '').trim()).filter((id) => id.length > 0), + ); + + if (normalizedEquipmentIds.size === 0) { + return { error: 'Aucun equipmentId valide.' }; + } + + const normalizedQuery = query ? normalizeSearchText(query) : null; + + const snapshot = await getDb().collection('containers').limit(300).get(); + const results = []; + + for (const doc of snapshot.docs) { + const data = doc.data(); + if (isCancelledStatus(data.status)) continue; + + const containerEquipmentIds = Array.isArray(data.equipmentIds) + ? data.equipmentIds.map((id) => String(id || '').trim()).filter(Boolean) + : []; + + if (containerEquipmentIds.length === 0) continue; + + const matchingIds = containerEquipmentIds.filter((id) => normalizedEquipmentIds.has(id)); + if (matchingIds.length === 0) continue; + + if (normalizedQuery) { + const searchableText = normalizeSearchText(data.name || doc.id); + if (!searchableText.includes(normalizedQuery)) continue; + } + + results.push({ + id: doc.id, + name: data.name || doc.id, + type: data.type || '', + status: data.status || '', + equipmentIds: containerEquipmentIds, + totalItemCount: containerEquipmentIds.length, + matchingEquipmentIds: matchingIds, + matchingCount: matchingIds.length, + coverageRatio: matchingIds.length / normalizedEquipmentIds.size, + }); + } + + // Trier par meilleure couverture (plus de matching ET ratio le plus proche de 1) + results.sort((a, b) => { + if (b.matchingCount !== a.matchingCount) return b.matchingCount - a.matchingCount; + return b.coverageRatio - a.coverageRatio; + }); + + const limited = results.slice(0, 15); + + return { + requestedEquipmentIds: Array.from(normalizedEquipmentIds), + count: limited.length, + containers: limited, + }; +} + +/** + * Recherche un evenement de reference par nom/date, meme s'il est futur. + */ +async function toolSearchEventReference(query, dateHint) { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) { + return { query, count: 0, events: [] }; + } + + const hintedDate = parseDateHintFlexible(dateHint) || parseDateHintFlexible(query); + const hintedYmd = hintedDate ? dateToYmd(hintedDate) : null; + const queryTokens = extractQueryTokens(query); + + let docs = []; + + // Priorite a une recherche ciblee autour de la date demandee. + if (hintedDate) { + const dayStart = new Date(Date.UTC( + hintedDate.getUTCFullYear(), + hintedDate.getUTCMonth(), + hintedDate.getUTCDate(), + 0, + 0, + 0, + 0, + )); + const dayEnd = new Date(Date.UTC( + hintedDate.getUTCFullYear(), + hintedDate.getUTCMonth(), + hintedDate.getUTCDate(), + 23, + 59, + 59, + 999, + )); + const rangeStart = new Date(dayStart.getTime() - (24 * 60 * 60 * 1000)); + const rangeEnd = new Date(dayEnd.getTime() + (24 * 60 * 60 * 1000)); + + try { + const byDateSnapshot = await getDb().collection('events') + .where('StartDateTime', '>=', rangeStart) + .where('StartDateTime', '<=', rangeEnd) + .orderBy('StartDateTime', 'desc') + .limit(Math.max(EVENT_SEARCH_SCAN_LIMIT, 150)) + .get(); + docs = byDateSnapshot.docs; + } catch (error) { + logger.warn('[AI] search_event_reference date query failed, fallback to scan', { + message: error?.message || 'unknown', + }); + } + } + + if (docs.length === 0) { + const snapshot = await getDb().collection('events') + .orderBy('StartDateTime', 'desc') + .limit(EVENT_SEARCH_SCAN_LIMIT) + .get(); + docs = snapshot.docs; + } + + const candidates = docs.map((doc) => { + const data = doc.data(); + const startDate = toDateSafe(data.StartDateTime); + const endDate = toDateSafe(data.EndDateTime); + const eventName = (data.name || data.Name || '').toString(); + const searchableText = normalizeSearchText([eventName, doc.id, data.eventTypeId || ''].join(' ')); + + const assignedEquipment = Array.isArray(data.assignedEquipment) ? data.assignedEquipment : []; + const assignedContainers = Array.isArray(data.assignedContainers) ? data.assignedContainers : []; + const matchedTokenCount = queryTokens.filter((token) => searchableText.includes(token)).length; + const hasTokenMatch = queryTokens.length === 0 + ? searchableText.includes(normalizedQuery) + : matchedTokenCount >= Math.min(2, queryTokens.length); + const matchesDate = isDateMatchingHint(startDate, hintedYmd); + const eventYmd = startDate ? dateToYmd(startDate) : null; + + return { + id: doc.id, + name: eventName || 'Sans nom', + startDate, + endDate, + eventYmd, + assignedEquipment, + assignedContainers, + assignedEquipmentCount: assignedEquipment.length, + matchesQuery: hasTokenMatch, + matchedTokenCount, + matchesDate, + }; + }); + + const strictMatched = candidates + .filter((event) => event.matchesQuery && event.matchesDate); + const fallbackMatched = candidates + .filter((event) => event.matchesQuery); + const selectedCandidates = strictMatched.length > 0 ? strictMatched : fallbackMatched; + + const matched = selectedCandidates + .sort((a, b) => { + const aScore = (a.matchesDate ? 3 : 0) + (a.assignedEquipmentCount > 0 ? 1 : 0) + a.matchedTokenCount; + const bScore = (b.matchesDate ? 3 : 0) + (b.assignedEquipmentCount > 0 ? 1 : 0) + b.matchedTokenCount; + if (bScore !== aScore) return bScore - aScore; + + const aTime = a.startDate ? a.startDate.getTime() : 0; + const bTime = b.startDate ? b.startDate.getTime() : 0; + return bTime - aTime; + }) + .slice(0, 5) + .map((event) => ({ + id: event.id, + name: event.name, + startDate: event.startDate ? event.startDate.toISOString() : null, + endDate: event.endDate ? event.endDate.toISOString() : null, + assignedEquipment: event.assignedEquipment.map((eq) => ({ + equipmentId: eq.equipmentId, + quantity: eq.quantity || 1, + })), + assignedContainers: event.assignedContainers, + assignedEquipmentCount: event.assignedEquipmentCount, + })); + + return { + query, + dateHint: hintedYmd || null, + usedDateFallback: strictMatched.length === 0 && Boolean(hintedYmd), + count: matched.length, + events: matched, + }; +} + +/** + * Exécute un tool Gemini et retourne le résultat. + */ +async function executeTool(toolCall, excludeEventId, sharedContext) { + const { name, args } = toolCall; + logger.info(`[AI] Executing tool: ${name}`, { args }); + + try { + switch (name) { + case 'search_equipment': + return await toolSearchEquipment(args.query); + + case 'check_availability': + return await toolCheckAvailability( + args.equipmentId, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, + ); + + case 'check_availability_batch': + return await toolCheckAvailabilityBatch( + args.equipmentIds, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, + ); + + case 'get_past_events': + return await toolGetPastEvents(args.eventTypeId || null); + + case 'search_event_reference': + return await toolSearchEventReference(args.query, args.dateHint || null); + + case 'search_containers': + return await toolSearchContainers(args.equipmentIds, args.query || null); + + default: + return { error: `Tool inconnu: ${name}` }; + } + } catch (err) { + logger.error(`[AI] Tool error (${name}):`, err); + return { error: err.message }; + } +} + +// ============================================================================ +// Gestionnaire principal +// ============================================================================ + +/** + * Construit le prompt utilisateur avec le contexte de l'événement. + */ +function buildUserPrompt({ + userMessage, + eventTypeId, + startDate, + endDate, + currentEquipment, + workingProposal, +}) { + const currentEquipmentStr = currentEquipment && currentEquipment.length > 0 + ? currentEquipment.map((eq) => `${eq.equipmentId} x${eq.quantity || 1}`).join(', ') + : 'aucun'; + const workingProposalStr = workingProposal && workingProposal.length > 0 + ? workingProposal.map((eq) => `${eq.equipmentId} x${eq.quantity || 1}`).join(', ') + : 'aucune'; + + 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.' + : userMessage.trim(); + + return [ + 'Contexte de l\'événement :', + `- Type d'événement (ID): ${eventTypeId || 'non renseigné'}`, + `- Date de début : ${startDate}`, + `- Date de fin : ${endDate}`, + `- Matériel déjà assigné : ${currentEquipmentStr}`, + `- Proposition courante à modifier : ${workingProposalStr}`, + '', + 'Demande :', + finalMessage, + '', + 'Si la demande cite un evenement precis (nom/date), commence par search_event_reference avant de proposer du materiel.', + 'Si une proposition courante existe, traite-la comme base de travail et renvoie toujours la liste finale complete apres modification.', + 'Rappel : vérifier la disponibilité avant de recommander. Privilégier check_availability_batch pour contrôler plusieurs équipements en un appel.', + 'En cas d\'indisponibilité, chercher une alternative via search_equipment puis revérifier.', + ].join('\n'); +} + +/** + * Extrait un texte exploitable depuis la réponse Gemini, même si response.text() est vide. + */ +function extractResponseText(modelResponse) { + if (!modelResponse) return ''; + + try { + const directText = modelResponse.text?.(); + if (directText && directText.trim().length > 0) { + return directText.trim(); + } + } catch (_) { + // Fallback via candidates/parts ci-dessous. + } + + const candidates = Array.isArray(modelResponse.candidates) ? modelResponse.candidates : []; + const textParts = []; + + for (const candidate of candidates) { + const parts = candidate?.content?.parts; + if (!Array.isArray(parts)) continue; + + for (const part of parts) { + if (typeof part?.text === 'string' && part.text.trim().length > 0) { + textParts.push(part.text.trim()); + } + } + } + + return textParts.join('\n').trim(); +} + +/** + * Extrait et parse le JSON de la réponse IA. + */ +function parseAiResponse(rawText) { + if (!rawText || rawText.trim().length === 0) { + throw new Error('Réponse IA vide.'); + } + + // Tentative directe + try { + const parsed = JSON.parse(rawText.trim()); + if (parsed && typeof parsed === 'object') return parsed; + } catch (_) { + // Continuer avec extraction depuis markdown + } + + // Extraction depuis un bloc markdown ```json ... ``` + const fencedMatch = rawText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fencedMatch) { + try { + const parsed = JSON.parse(fencedMatch[1]); + if (parsed && typeof parsed === 'object') return parsed; + } catch (_) { + // Continuer + } + } + + // Extraction du premier objet JSON brut dans le texte + const jsonMatch = rawText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[0]); + if (parsed && typeof parsed === 'object') return parsed; + } catch (_) { + // Échoue + } + } + + throw new Error('JSON IA invalide ou introuvable dans la réponse.'); +} + +/** + * Handler principal de la Cloud Function aiEquipmentProposal. + */ +async function handleAiEquipmentProposal(req, res) { + const { + eventTypeId, + startDate, + endDate, + userMessage, + history = [], + currentEquipment = [], + workingProposal = [], + excludeEventId, + } = req.body.data || {}; + + if (!startDate || !endDate) { + res.status(400).json({ error: 'startDate et endDate sont requis.' }); + return; + } + + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ + model: GEMINI_MODEL, + systemInstruction: SYSTEM_PROMPT, + tools: AI_TOOLS, + toolConfig: { functionCallingConfig: { mode: 'AUTO' } }, + generationConfig: { temperature: 0.2 }, + }); + + // Reconstruire l'historique de conversation + const chatHistory = (history || []) + .filter((turn) => turn.text && turn.text.trim().length > 0) + .map((turn) => ({ + role: turn.isUser ? 'user' : 'model', + parts: [{ text: turn.text.trim() }], + })); + + const chat = model.startChat({ history: chatHistory }); + const toolResultCache = new Map(); + const sharedToolContext = { + availabilityCandidatesByWindow: new Map(), + }; + + const userPrompt = buildUserPrompt({ + userMessage, + eventTypeId, + startDate, + endDate, + currentEquipment, + workingProposal, + }); + + logger.info('[AI] Starting conversation', { eventTypeId, startDate, endDate }); + + let response; + + try { + response = await chat.sendMessage(userPrompt); + + // Boucle de function calling avec cache local. + for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { + const functionCalls = response.response.functionCalls(); + + if (!functionCalls || functionCalls.length === 0) { + break; + } + + const limitedCalls = functionCalls.slice(0, MAX_TOOL_CALLS_PER_ITERATION); + const availabilityCalls = limitedCalls.filter( + (call) => call.name === 'check_availability' + && call.args?.equipmentId + && call.args?.startDate + && call.args?.endDate, + ); + + let batchAvailabilityMap = null; + let batchWindow = null; + + if (availabilityCalls.length >= 2) { + const firstStartDate = availabilityCalls[0].args.startDate; + const firstEndDate = availabilityCalls[0].args.endDate; + const hasSameWindow = availabilityCalls.every( + (call) => call.args.startDate === firstStartDate && call.args.endDate === firstEndDate, + ); + + if (hasSameWindow) { + const equipmentIds = Array.from(new Set( + availabilityCalls.map((call) => String(call.args.equipmentId)), + )).sort(); + const batchArgs = { + equipmentIds, + startDate: firstStartDate, + endDate: firstEndDate, + }; + const batchCacheKey = buildToolCacheKey('check_availability_batch', batchArgs); + + let batchResult = toolResultCache.get(batchCacheKey); + if (!batchResult) { + batchResult = await executeTool( + { name: 'check_availability_batch', args: batchArgs }, + excludeEventId, + sharedToolContext, + ); + toolResultCache.set(batchCacheKey, batchResult); + } + + if (batchResult && Array.isArray(batchResult.results)) { + batchAvailabilityMap = new Map( + batchResult.results.map((item) => [String(item.equipmentId), item]), + ); + batchWindow = { startDate: firstStartDate, endDate: firstEndDate }; + logger.info('[AI] Consolidated check_availability calls into one batch call', { + iteration: iteration + 1, + itemCount: equipmentIds.length, + }); + } + } + } + + logger.info(`[AI] Tool calls (iteration ${iteration + 1}):`, limitedCalls.map((c) => c.name)); + + const toolResults = await Promise.all( + limitedCalls.map(async (toolCall) => { + const cacheKey = buildToolCacheKey(toolCall.name, toolCall.args); + if (toolResultCache.has(cacheKey)) { + return { + functionResponse: { + name: toolCall.name, + response: toolResultCache.get(cacheKey), + }, + }; + } + + let toolResult; + try { + if ( + batchAvailabilityMap + && batchWindow + && toolCall.name === 'check_availability' + && toolCall.args?.startDate === batchWindow.startDate + && toolCall.args?.endDate === batchWindow.endDate + ) { + toolResult = batchAvailabilityMap.get(String(toolCall.args.equipmentId)) || { + equipmentId: toolCall.args.equipmentId, + available: true, + conflictCount: 0, + conflicts: [], + }; + } else { + toolResult = await executeTool(toolCall, excludeEventId, sharedToolContext); + } + } catch (toolError) { + logger.warn('[AI] Tool call failed, returning degraded tool response', { + tool: toolCall.name, + message: toolError?.message || 'unknown', + }); + toolResult = { error: toolError?.message || `Echec du tool ${toolCall.name}` }; + } + + toolResultCache.set(cacheKey, toolResult); + + return { + functionResponse: { + name: toolCall.name, + response: toolResult, + }, + }; + }), + ); + + response = await chat.sendMessage(toolResults); + } + } catch (error) { + logger.error('[AI] Conversation timeout/error', { + message: error?.message || 'unknown', + }); + res.status(200).json({ + assistantMessage: 'La generation IA a rencontre une erreur technique. Reessaie dans quelques secondes.', + proposal: null, + }); + return; + } + + const rawText = extractResponseText(response.response); + logger.info('[AI] Raw response received, parsing...', { + hasText: rawText.length > 0, + candidateCount: response.response?.candidates?.length || 0, + }); + + // Fallback non bloquant: éviter un 500 quand Gemini ne renvoie pas de texte exploitable. + if (!rawText) { + res.status(200).json({ + assistantMessage: 'Je n\'ai pas pu générer une réponse exploitable pour le moment. Réessaie avec une consigne plus précise.', + proposal: null, + }); + return; + } + + let payload; + try { + payload = parseAiResponse(rawText); + } catch (error) { + logger.error('[AI] JSON parsing failed, returning degraded response', error); + res.status(200).json({ + assistantMessage: rawText, + proposal: null, + }); + return; + } + + const assistantMessage = payload.assistantMessage?.toString().trim() || rawText; + + // Normaliser la proposition (items + containers) + let proposal = null; + if (payload.proposal) { + const rawItems = Array.isArray(payload.proposal.items) ? payload.proposal.items : []; + const rawContainers = Array.isArray(payload.proposal.containers) ? payload.proposal.containers : []; + + const items = rawItems + .filter((item) => item.equipmentId && item.quantity > 0) + .map((item) => ({ + equipmentId: String(item.equipmentId).trim(), + quantity: Math.max(1, parseInt(item.quantity) || 1), + rationale: (item.rationale || 'Proposition IA').trim(), + })); + + const containers = rawContainers + .filter((c) => c.containerId) + .map((c) => ({ + containerId: String(c.containerId).trim(), + rationale: (c.rationale || 'Proposition IA').trim(), + })); + + if (items.length > 0 || containers.length > 0) { + proposal = { + summary: payload.proposal.summary?.toString().trim() || 'Proposition generee automatiquement.', + items, + containers, + }; + } + } + + res.status(200).json({ assistantMessage, proposal }); +} + +module.exports = { handleAiEquipmentProposal }; + diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 2bec9a4..f4bb1fa 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -17,6 +17,7 @@ const { Storage } = require('@google-cloud/storage'); const auth = require('./utils/auth'); const helpers = require('./utils/helpers'); const { generateTTS } = require('./generateTTS'); +const { handleAiEquipmentProposal } = require('./aiEquipmentProposal'); // Initialisation sécurisée if (!admin.apps.length) { @@ -33,6 +34,13 @@ const httpOptions = { // Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS }; +// Options dédiées pour les traitements IA potentiellement longs. +const aiHttpOptions = { + ...httpOptions, + timeoutSeconds: 300, + memory: '1GiB', +}; + // ============================================================================ // CORS Middleware // ============================================================================ @@ -4263,3 +4271,20 @@ exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => { } }); +// ============================================================================ +// AI - Assistant Logisticien (Gemini avec function calling côté serveur) +// ============================================================================ + +exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors(async (req, res) => { + try { + // Authentification Firebase obligatoire (pas de clé API côté client) + await auth.authenticateUser(req); + await handleAiEquipmentProposal(req, res); + } catch (error) { + logger.error('[aiEquipmentProposal] Error:', error); + if (!res.headersSent) { + res.status(500).json({ error: error.message }); + } + } +})); + diff --git a/em2rp/functions/package-lock.json b/em2rp/functions/package-lock.json index 90587fb..2f39817 100644 --- a/em2rp/functions/package-lock.json +++ b/em2rp/functions/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@google-cloud/storage": "^7.18.0", "@google-cloud/text-to-speech": "^5.4.0", + "@google/generative-ai": "^0.21.0", "axios": "^1.13.2", "dotenv": "^17.2.3", "envdot": "^0.0.3", @@ -785,6 +786,15 @@ "node": ">=14.0.0" } }, + "node_modules/@google/generative-ai": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz", + "integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", diff --git a/em2rp/functions/package.json b/em2rp/functions/package.json index 934521e..b0740b0 100644 --- a/em2rp/functions/package.json +++ b/em2rp/functions/package.json @@ -16,6 +16,7 @@ "dependencies": { "@google-cloud/storage": "^7.18.0", "@google-cloud/text-to-speech": "^5.4.0", + "@google/generative-ai": "^0.21.0", "axios": "^1.13.2", "dotenv": "^17.2.3", "envdot": "^0.0.3", diff --git a/em2rp/lib/config/env.dart b/em2rp/lib/config/env.dart index eff3b62..539a953 100644 --- a/em2rp/lib/config/env.dart +++ b/em2rp/lib/config/env.dart @@ -1,9 +1,9 @@ class Env { - static const bool isDevelopment = false; + static const bool isDevelopment = true; // Configuration de l'auto-login en développement - static const String devAdminEmail = ''; - static const String devAdminPassword = ''; + static const String devAdminEmail = 'paul.fournel@em2events.fr'; + static const String devAdminPassword = 'Pastis51!'; // URLs et endpoints static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; @@ -14,3 +14,4 @@ class Env { // Autres configurations static const int apiTimeout = 30000; // 30 secondes } + diff --git a/em2rp/lib/services/ai_equipment_assistant_service.dart b/em2rp/lib/services/ai_equipment_assistant_service.dart new file mode 100644 index 0000000..9677ebe --- /dev/null +++ b/em2rp/lib/services/ai_equipment_assistant_service.dart @@ -0,0 +1,178 @@ +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 items; + + /// Équipements individuels prêts à être injectés dans l'état local de l'événement. + final List asEventEquipment; + + /// IDs des containers (flight cases) proposés par l'IA. + final List 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 generateProposal({ + required DateTime startDate, + required DateTime endDate, + required List history, + required String userMessage, + String? eventTypeId, + String? excludeEventId, + List currentAssignedEquipment = const [], + List workingProposalEquipment = const [], + }) 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; + + 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) return null; + + final proposalItems = []; + final eventEquipmentList = []; + final containerIds = []; + + 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)); + } + } + + final rawContainers = rawProposal['containers']; + if (rawContainers is List) { + for (final rawContainer in rawContainers) { + if (rawContainer is! Map) continue; + final container = Map.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, + ); + } +} diff --git a/em2rp/lib/services/data_service.dart b/em2rp/lib/services/data_service.dart index 7c62b13..b00cdf5 100644 --- a/em2rp/lib/services/data_service.dart +++ b/em2rp/lib/services/data_service.dart @@ -27,7 +27,8 @@ class DataService { if (eventTypes == null) return []; return eventTypes.map((e) => e as Map).toList(); } catch (e) { - throw Exception('Erreur lors de la récupération des types d\'événements: $e'); + throw Exception( + 'Erreur lors de la récupération des types d\'événements: $e'); } } @@ -55,15 +56,18 @@ class DataService { try { final data = {'eventId': eventId}; - if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment; - if (preparationStatus != null) data['preparationStatus'] = preparationStatus; + if (assignedEquipment != null) + data['assignedEquipment'] = assignedEquipment; + if (preparationStatus != null) + data['preparationStatus'] = preparationStatus; if (loadingStatus != null) data['loadingStatus'] = loadingStatus; if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus; if (returnStatus != null) data['returnStatus'] = returnStatus; await _apiService.call('updateEventEquipment', data); } catch (e) { - throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e'); + throw Exception( + 'Erreur lors de la mise à jour des équipements de l\'événement: $e'); } } @@ -77,11 +81,13 @@ class DataService { final data = {'equipmentId': equipmentId}; if (status != null) data['status'] = status; - if (availableQuantity != null) data['availableQuantity'] = availableQuantity; + if (availableQuantity != null) + data['availableQuantity'] = availableQuantity; await _apiService.call('updateEquipmentStatusOnly', data); } catch (e) { - throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e'); + throw Exception( + 'Erreur lors de la mise à jour du statut de l\'équipement: $e'); } } @@ -106,7 +112,8 @@ class DataService { } /// Crée un équipement - Future createEquipment(String equipmentId, Map data) async { + Future createEquipment( + String equipmentId, Map data) async { try { // S'assurer que l'ID est dans les données final equipmentData = Map.from(data); @@ -119,7 +126,8 @@ class DataService { } /// Met à jour un équipement - Future updateEquipment(String equipmentId, Map data) async { + Future updateEquipment( + String equipmentId, Map data) async { try { await _apiService.call('updateEquipment', { 'equipmentId': equipmentId, @@ -140,9 +148,11 @@ class DataService { } /// Récupère les événements utilisant un type d'événement donné - Future>> getEventsByEventType(String eventTypeId) async { + Future>> getEventsByEventType( + String eventTypeId) async { try { - final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId}); + final result = await _apiService + .call('getEventsByEventType', {'eventTypeId': eventTypeId}); final events = result['events'] as List?; if (events == null) return []; return events.map((e) => e as Map).toList(); @@ -271,7 +281,8 @@ class DataService { final events = result['events'] as List? ?? []; final users = result['users'] as Map? ?? {}; - print('[DataService] Events loaded for $year-$month: ${events.length} events'); + print( + '[DataService] Events loaded for $year-$month: ${events.length} events'); return { 'events': events.map((e) => e as Map).toList(), @@ -279,7 +290,8 @@ class DataService { }; } catch (e) { print('[DataService] Error getting events by month: $e'); - throw Exception('Erreur lors de la récupération des événements du mois: $e'); + throw Exception( + 'Erreur lors de la récupération des événements du mois: $e'); } } @@ -299,7 +311,8 @@ class DataService { throw Exception('Event not found'); } - print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); + print( + '[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); return { 'event': event, @@ -308,7 +321,8 @@ class DataService { }; } catch (e) { print('[DataService] Error getting event with details: $e'); - throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e'); + throw Exception( + 'Erreur lors de la récupération de l\'événement avec détails: $e'); } } @@ -332,11 +346,13 @@ class DataService { } /// Récupère plusieurs équipements par leurs IDs - Future>> getEquipmentsByIds(List equipmentIds) async { + Future>> getEquipmentsByIds( + List equipmentIds) async { try { if (equipmentIds.isEmpty) return []; - print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items'); + print( + '[DataService] Getting equipments by IDs: ${equipmentIds.length} items'); final result = await _apiService.call('getEquipmentsByIds', { 'equipmentIds': equipmentIds, }); @@ -366,11 +382,13 @@ class DataService { } /// Récupère plusieurs containers par leurs IDs - Future>> getContainersByIds(List containerIds) async { + Future>> getContainersByIds( + List containerIds) async { try { if (containerIds.isEmpty) return []; - print('[DataService] Getting containers by IDs: ${containerIds.length} items'); + print( + '[DataService] Getting containers by IDs: ${containerIds.length} items'); final result = await _apiService.call('getContainersByIds', { 'containerIds': containerIds, }); @@ -415,22 +433,25 @@ class DataService { params['searchQuery'] = searchQuery; } - final result = await (_apiService as FirebaseFunctionsApiService).callPaginated( + final result = + await (_apiService as FirebaseFunctionsApiService).callPaginated( 'getEquipmentsPaginated', params, ); return { 'equipments': (result['equipments'] as List?) - ?.map((e) => e as Map) - .toList() ?? [], + ?.map((e) => e as Map) + .toList() ?? + [], 'hasMore': result['hasMore'] as bool? ?? false, 'lastVisible': result['lastVisible'] as String?, 'total': result['total'] as int? ?? 0, }; } catch (e) { DebugLog.error('[DataService] Error in getEquipmentsPaginated', e); - throw Exception('Erreur lors de la récupération paginée des équipements: $e'); + throw Exception( + 'Erreur lors de la récupération paginée des équipements: $e'); } } @@ -460,22 +481,25 @@ class DataService { params['searchQuery'] = searchQuery; } - final result = await (_apiService as FirebaseFunctionsApiService).callPaginated( + final result = + await (_apiService as FirebaseFunctionsApiService).callPaginated( 'getContainersPaginated', params, ); return { 'containers': (result['containers'] as List?) - ?.map((e) => e as Map) - .toList() ?? [], + ?.map((e) => e as Map) + .toList() ?? + [], 'hasMore': result['hasMore'] as bool? ?? false, 'lastVisible': result['lastVisible'] as String?, 'total': result['total'] as int? ?? 0, }; } catch (e) { DebugLog.error('[DataService] Error in getContainersPaginated', e); - throw Exception('Erreur lors de la récupération paginée des containers: $e'); + throw Exception( + 'Erreur lors de la récupération paginée des containers: $e'); } } @@ -499,6 +523,156 @@ class DataService { } } + /// Recherche des équipements pour l'assistant IA avec fallback paginé. + Future>> searchEquipmentsForAssistant({ + required String query, + int limit = 12, + }) async { + final normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty) { + return []; + } + + try { + final quickResults = await quickSearch( + normalizedQuery, + limit: limit, + includeEquipments: true, + includeContainers: false, + ); + + final equipmentResults = quickResults + .where((item) => + (item['type']?.toString().toLowerCase() ?? '') == 'equipment') + .map(_normalizeAssistantEquipment) + .toList(); + + if (equipmentResults.isNotEmpty) { + return equipmentResults; + } + + final paginated = await getEquipmentsPaginated( + limit: limit, + searchQuery: normalizedQuery, + sortBy: 'id', + sortOrder: 'asc', + ); + + final equipments = + paginated['equipments'] as List>? ?? []; + return equipments.map(_normalizeAssistantEquipment).toList(); + } catch (e) { + DebugLog.error('[DataService] Error in searchEquipmentsForAssistant', e); + throw Exception('Erreur lors de la recherche de matériel: $e'); + } + } + + /// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA. + Future> checkEquipmentAvailabilityForAssistant({ + required String equipmentId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + try { + final result = await checkEquipmentAvailability( + equipmentId: equipmentId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + + final available = result['available'] as bool? ?? true; + final conflicts = (result['conflicts'] as List? ?? const []) + .whereType>() + .map((conflict) { + final eventData = + conflict['eventData'] as Map? ?? const {}; + final eventName = + (eventData['Name'] ?? conflict['eventName'] ?? '').toString(); + return { + 'eventId': conflict['eventId']?.toString() ?? '', + 'eventName': eventName, + 'overlapDays': conflict['overlapDays'] as int? ?? 0, + }; + }).toList(); + + return { + 'equipmentId': equipmentId, + 'available': available, + 'conflictCount': conflicts.length, + 'conflicts': conflicts, + }; + } catch (e) { + DebugLog.error( + '[DataService] Error in checkEquipmentAvailabilityForAssistant', e); + throw Exception('Erreur lors de la vérification de disponibilité: $e'); + } + } + + /// Retourne des événements passés, idéalement filtrés par type d'événement. + Future>> getPastEventsForAssistant({ + String? eventTypeId, + int limit = 10, + }) async { + try { + final now = DateTime.now(); + final events = eventTypeId != null && eventTypeId.isNotEmpty + ? await getEventsByEventType(eventTypeId) + : (await getEvents())['events'] as List>? ?? []; + + final pastEvents = events.where((event) { + final endDate = _parseEventDate(event['EndDateTime']); + return endDate != null && endDate.isBefore(now); + }).toList(); + + pastEvents.sort((a, b) { + final aDate = _parseEventDate(a['StartDateTime']) ?? + DateTime.fromMillisecondsSinceEpoch(0); + final bDate = _parseEventDate(b['StartDateTime']) ?? + DateTime.fromMillisecondsSinceEpoch(0); + return bDate.compareTo(aDate); + }); + + return pastEvents.take(limit).map((event) { + final assignedEquipment = + event['assignedEquipment'] as List? ?? const []; + return { + 'id': event['id']?.toString() ?? '', + 'name': (event['Name'] ?? '').toString(), + 'startDate': event['StartDateTime']?.toString() ?? '', + 'endDate': event['EndDateTime']?.toString() ?? '', + 'assignedEquipment': assignedEquipment, + 'assignedEquipmentCount': assignedEquipment.length, + }; + }).toList(); + } catch (e) { + DebugLog.error('[DataService] Error in getPastEventsForAssistant', e); + throw Exception( + 'Erreur lors de la récupération des événements passés: $e'); + } + } + + Map _normalizeAssistantEquipment(Map item) { + return { + 'id': (item['id'] ?? '').toString(), + 'name': (item['name'] ?? item['id'] ?? '').toString(), + 'category': (item['category'] ?? '').toString(), + 'status': (item['status'] ?? '').toString(), + 'brand': item['brand']?.toString(), + 'model': item['model']?.toString(), + 'availableQuantity': item['availableQuantity'], + 'totalQuantity': item['totalQuantity'], + }; + } + + DateTime? _parseEventDate(dynamic rawValue) { + if (rawValue is String) { + return DateTime.tryParse(rawValue); + } + return null; + } + // ============================================================================ // USER - Current User // ============================================================================ @@ -512,7 +686,8 @@ class DataService { return result['user'] as Map; } catch (e) { print('[DataService] Error getting current user: $e'); - throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e'); + throw Exception( + 'Erreur lors de la récupération de l\'utilisateur actuel: $e'); } } @@ -593,7 +768,8 @@ class DataService { }); return result; } catch (e) { - throw Exception('Erreur lors de la récupération des équipements en conflit: $e'); + throw Exception( + 'Erreur lors de la récupération des équipements en conflit: $e'); } } @@ -602,7 +778,8 @@ class DataService { // ============================================================================ /// Récupère toutes les maintenances - Future>> getMaintenances({String? equipmentId}) async { + Future>> getMaintenances( + {String? equipmentId}) async { try { final data = {}; if (equipmentId != null) data['equipmentId'] = equipmentId; @@ -619,14 +796,16 @@ class DataService { /// Supprime une maintenance Future deleteMaintenance(String maintenanceId) async { try { - await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId}); + await _apiService + .call('deleteMaintenance', {'maintenanceId': maintenanceId}); } catch (e) { throw Exception('Erreur lors de la suppression de la maintenance: $e'); } } /// Récupère les containers contenant un équipement - Future>> getContainersByEquipment(String equipmentId) async { + Future>> getContainersByEquipment( + String equipmentId) async { try { final result = await _apiService.call('getContainersByEquipment', { 'equipmentId': equipmentId, diff --git a/em2rp/lib/views/event_add_page.dart b/em2rp/lib/views/event_add_page.dart index 4344e6c..2620f95 100644 --- a/em2rp/lib/views/event_add_page.dart +++ b/em2rp/lib/views/event_add_page.dart @@ -77,7 +77,8 @@ class _EventAddEditPageState extends State { return; } - final success = await _controller.submitForm(context, existingEvent: widget.event); + final success = + await _controller.submitForm(context, existingEvent: widget.event); if (success && mounted) { Navigator.of(context).pop(); } @@ -158,21 +159,25 @@ class _EventAddEditPageState extends State { }, child: Scaffold( appBar: AppBar( - title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'), + title: Text( + isEditMode ? 'Modifier un événement' : 'Créer un événement'), ), body: Center( child: SingleChildScrollView( child: (isMobile ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), child: _buildFormContent(isMobile), ) : Card( elevation: 6, margin: const EdgeInsets.all(24), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18)), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), + padding: const EdgeInsets.symmetric( + horizontal: 32, vertical: 32), child: _buildFormContent(isMobile), ), )), @@ -186,15 +191,6 @@ class _EventAddEditPageState extends State { Widget _buildFormContent(bool isMobile) { return Consumer( builder: (context, controller, child) { - // Trouver le nom du type d'événement pour le passer au sélecteur d'options - final selectedEventTypeIndex = controller.selectedEventTypeId != null - ? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId) - : -1; - final selectedEventType = selectedEventTypeIndex != -1 - ? controller.eventTypes[selectedEventTypeIndex] - : null; - final selectedEventTypeName = selectedEventType?.name; - return Form( key: _formKey, child: Column( @@ -209,18 +205,22 @@ class _EventAddEditPageState extends State { selectedEventTypeId: controller.selectedEventTypeId, startDateTime: controller.startDateTime, endDateTime: controller.endDateTime, - onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context), + onEventTypeChanged: (typeId) => + controller.onEventTypeChanged(typeId, context), onStartDateTimeChanged: controller.setStartDateTime, onEndDateTimeChanged: controller.setEndDateTime, - onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur + onAnyFieldChanged: + () {}, // Géré automatiquement par le contrôleur ), const SizedBox(height: 16), OptionSelectorWidget( - eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom + eventType: controller + .selectedEventTypeId, // Utilise l'ID au lieu du nom selectedOptions: controller.selectedOptions, onChanged: controller.setSelectedOptions, onRemove: (optionId) { - final newOptions = List>.from(controller.selectedOptions); + final newOptions = List>.from( + controller.selectedOptions); newOptions.removeWhere((o) => o['id'] == optionId); controller.setSelectedOptions(newOptions); }, @@ -236,6 +236,7 @@ class _EventAddEditPageState extends State { endDate: controller.endDateTime, onChanged: controller.setAssignedEquipment, eventId: widget.event?.id, + eventTypeId: controller.selectedEventTypeId, ), const SizedBox(height: 16), EventDetailsSection( @@ -247,7 +248,8 @@ class _EventAddEditPageState extends State { contactEmailController: controller.contactEmailController, contactPhoneController: controller.contactPhoneController, isMobile: isMobile, - onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur + onAnyFieldChanged: + () {}, // Géré automatiquement par le contrôleur ), EventStaffAndDocumentsSection( allUsers: controller.allUsers, @@ -290,9 +292,10 @@ class _EventAddEditPageState extends State { } }, onSubmit: _submit, - onSetConfirmed: !isEditMode ? () { - } : null, - onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression + onSetConfirmed: !isEditMode ? () {} : null, + onDelete: isEditMode + ? _deleteEvent + : null, // Ajout du callback de suppression ), ], ), diff --git a/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart new file mode 100644 index 0000000..9db9782 --- /dev/null +++ b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart @@ -0,0 +1,384 @@ +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/services/ai_equipment_assistant_service.dart'; +import 'package:flutter/material.dart'; + +/// Résultat retourné par le dialog après confirmation de la proposition IA. +class AiProposalResult { + final List equipment; + final List containerIds; + + const AiProposalResult({ + required this.equipment, + required this.containerIds, + }); +} + +class AiEquipmentAssistantDialog extends StatefulWidget { + final DateTime startDate; + final DateTime endDate; + final String? eventTypeId; + final String? excludeEventId; + final List currentAssignedEquipment; + + const AiEquipmentAssistantDialog({ + super.key, + required this.startDate, + required this.endDate, + required this.currentAssignedEquipment, + this.eventTypeId, + this.excludeEventId, + }); + + @override + State createState() => + _AiEquipmentAssistantDialogState(); +} + +class _AiEquipmentAssistantDialogState + extends State { + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final ScrollController _proposalScrollController = ScrollController(); + final List<_AssistantChatMessage> _messages = []; + + late final AiEquipmentAssistantService _assistantService; + + bool _isLoading = false; + String? _errorMessage; + AiEquipmentProposal? _latestProposal; + late List _workingEquipment; + + @override + void initState() { + super.initState(); + _assistantService = AiEquipmentAssistantService(); + _workingEquipment = List.from(widget.currentAssignedEquipment); + } + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + _proposalScrollController.dispose(); + super.dispose(); + } + + bool get _isChatEmpty => _messages.isEmpty; + + String get _actionButtonLabel { + return _isChatEmpty ? 'Generer la liste automatiquement' : 'Envoyer'; + } + + Future _sendMessage() async { + if (_isLoading) { + return; + } + + final rawInput = _messageController.text.trim(); + final isAutoMode = _isChatEmpty; + final userMessage = isAutoMode + ? (rawInput.isNotEmpty + ? rawInput + : 'Genere automatiquement une proposition de materiel pour cet evenement.') + : rawInput; + + if (userMessage.isEmpty) { + return; + } + + _messageController.clear(); + setState(() { + _errorMessage = null; + _messages.add(_AssistantChatMessage.user(userMessage)); + _isLoading = true; + }); + + _scrollToBottom(); + + try { + final response = await _assistantService + .generateProposal( + startDate: widget.startDate, + endDate: widget.endDate, + eventTypeId: widget.eventTypeId, + excludeEventId: widget.excludeEventId, + currentAssignedEquipment: widget.currentAssignedEquipment, + workingProposalEquipment: _workingEquipment, + userMessage: userMessage, + history: _messages + .map((message) => AiAssistantChatTurn( + isUser: message.isUser, text: message.text)) + .toList(), + ); + + if (!mounted) { + return; + } + + setState(() { + _messages + .add(_AssistantChatMessage.assistant(response.assistantMessage)); + _latestProposal = response.proposal; + if (response.proposal != null) { + _workingEquipment = List.from( + response.proposal!.asEventEquipment, + ); + } + _isLoading = false; + }); + _scrollToBottom(); + } on FormatException catch (error) { + if (!mounted) { + return; + } + setState(() { + _isLoading = false; + _errorMessage = 'Reponse IA invalide: ${error.message}'; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _isLoading = false; + _errorMessage = 'Erreur IA: $error'; + }); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) { + return; + } + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + child: SizedBox( + width: 760, + height: 640, + child: Column( + children: [ + AppBar( + automaticallyImplyLeading: false, + title: const Text('Assistant IA Logisticien'), + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: + _isLoading ? null : () => Navigator.of(context).pop(), + ), + ], + ), + Expanded( + child: Column( + children: [ + Expanded( + child: Container( + color: Colors.grey.shade50, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + return _buildMessageBubble(message); + }, + ), + ), + ), + if (_isLoading) + const Padding( + padding: + EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text( + 'Generation en cours... verification du materiel et disponibilites.'), + ], + ), + ), + if (_errorMessage != null) + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.red.shade200), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: Colors.red.shade800), + ), + ), + if (_latestProposal != null) + _buildProposalSummary(_latestProposal!), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + enabled: !_isLoading, + minLines: 1, + maxLines: 3, + decoration: const InputDecoration( + hintText: + 'Precisez votre besoin (style, jauge, contraintes...)', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _isLoading ? null : _sendMessage, + child: Text(_actionButtonLabel), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildMessageBubble(_AssistantChatMessage message) { + final bubbleColor = message.isUser ? Colors.blue.shade600 : Colors.white; + final textColor = message.isUser ? Colors.white : Colors.black87; + + return Align( + alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + constraints: const BoxConstraints(maxWidth: 560), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.circular(12), + border: + message.isUser ? null : Border.all(color: Colors.grey.shade300), + ), + child: Text( + message.text, + style: TextStyle(color: textColor), + ), + ), + ); + } + + Widget _buildProposalSummary(AiEquipmentProposal proposal) { + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(maxHeight: 240), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.green.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Recapitulatif propose', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Flexible( + child: Scrollbar( + controller: _proposalScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _proposalScrollController, + padding: const EdgeInsets.only(right: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(proposal.summary), + if (proposal.items.isNotEmpty) ...[ + const SizedBox(height: 8), + ...proposal.items.map((item) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + '- ${item.equipmentId} x${item.quantity} - ${item.rationale}', + ), + ); + }), + ], + if (proposal.containerIds.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text( + 'Boites proposees :', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ...proposal.containerIds.map((id) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('- $id'), + ); + }), + ], + ], + ), + ), + ), + ), + const SizedBox(height: 10), + ElevatedButton.icon( + onPressed: _isLoading + ? null + : () => Navigator.of(context).pop( + AiProposalResult( + equipment: proposal.asEventEquipment, + containerIds: proposal.containerIds, + ), + ), + icon: const Icon(Icons.add_task), + label: const Text('Confirmer et Ajouter'), + ), + ], + ), + ); + } +} + +class _AssistantChatMessage { + final bool isUser; + final String text; + + const _AssistantChatMessage._({required this.isUser, required this.text}); + + factory _AssistantChatMessage.user(String text) { + return _AssistantChatMessage._(isUser: true, text: text); + } + + factory _AssistantChatMessage.assistant(String text) { + return _AssistantChatMessage._(isUser: false, text: text); + } +} diff --git a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart index c10eb82..4da6663 100644 --- a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart @@ -8,6 +8,7 @@ import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart'; +import 'package:em2rp/views/widgets/event_form/ai_equipment_assistant_dialog.dart'; /// Section pour afficher et gérer le matériel assigné à un événement class EventAssignedEquipmentSection extends StatefulWidget { @@ -17,6 +18,7 @@ class EventAssignedEquipmentSection extends StatefulWidget { final DateTime? endDate; final Function(List, List) onChanged; final String? eventId; // Pour exclure l'événement actuel de la vérification + final String? eventTypeId; const EventAssignedEquipmentSection({ super.key, @@ -26,14 +28,18 @@ class EventAssignedEquipmentSection extends StatefulWidget { required this.endDate, required this.onChanged, this.eventId, + this.eventTypeId, }); @override - State createState() => _EventAssignedEquipmentSectionState(); + State createState() => + _EventAssignedEquipmentSectionState(); } -class _EventAssignedEquipmentSectionState extends State { - bool get _canAddMaterial => widget.startDate != null && widget.endDate != null; +class _EventAssignedEquipmentSectionState + extends State { + bool get _canAddMaterial => + widget.startDate != null && widget.endDate != null; final Map _equipmentCache = {}; final Map _containerCache = {}; bool _isLoading = true; @@ -61,19 +67,24 @@ class _EventAssignedEquipmentSectionState extends State(); final containerProvider = context.read(); - DebugLog.info('[EventAssignedEquipmentSection] Loading caches from assigned lists'); + DebugLog.info( + '[EventAssignedEquipmentSection] Loading caches from assigned lists'); // Toujours partir des données locales du formulaire pour éviter les décalages visuels. - final equipmentIds = widget.assignedEquipment.map((eq) => eq.equipmentId).toList(); - final containers = await containerProvider.getContainersByIds(widget.assignedContainers); + final equipmentIds = + widget.assignedEquipment.map((eq) => eq.equipmentId).toList(); + final containers = + await containerProvider.getContainersByIds(widget.assignedContainers); final childEquipmentIds = []; for (final container in containers) { childEquipmentIds.addAll(container.equipmentIds); } - final allEquipmentIds = {...equipmentIds, ...childEquipmentIds}.toList(); - final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds); + final allEquipmentIds = + {...equipmentIds, ...childEquipmentIds}.toList(); + final equipment = + await equipmentProvider.getEquipmentsByIds(allEquipmentIds); _equipmentCache.clear(); _containerCache.clear(); @@ -110,7 +121,9 @@ class _EventAssignedEquipmentSectionState extends State _isLoading = false); } @@ -138,7 +151,8 @@ class _EventAssignedEquipmentSectionState extends State _processSelection(Map selection) async { - DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items'); + DebugLog.info( + '[EventAssignedEquipmentSection] Processing selection of ${selection.length} items'); // Séparer équipements et conteneurs final newEquipment = []; @@ -155,23 +169,27 @@ class _EventAssignedEquipmentSectionState extends State(); - final containers = await containerProvider.getContainersByIds(newContainers); + final containers = + await containerProvider.getContainersByIds(newContainers); for (var container in containers) { for (var childEquipmentId in container.equipmentIds) { // Vérifier si l'équipement enfant n'est pas déjà dans la liste - final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId); + final existsInNew = + newEquipment.any((eq) => eq.equipmentId == childEquipmentId); if (!existsInNew) { newEquipment.add(EventEquipment( equipmentId: childEquipmentId, quantity: 1, )); - DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}'); + DebugLog.info( + '[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}'); } } } @@ -183,11 +201,12 @@ class _EventAssignedEquipmentSectionState extends State e.equipmentId == eq.equipmentId); - + final existingIndex = + updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId); + if (existingIndex != -1) { // L'équipement existe déjà : mettre à jour la quantité updatedEquipment[existingIndex] = EventEquipment( @@ -204,17 +223,64 @@ class _EventAssignedEquipmentSectionState extends State _openAiAssistantDialog() async { + if (widget.startDate == null || widget.endDate == null) { + return; + } + + final result = await showDialog( + context: context, + builder: (context) => AiEquipmentAssistantDialog( + startDate: widget.startDate!, + endDate: widget.endDate!, + eventTypeId: widget.eventTypeId, + excludeEventId: widget.eventId, + currentAssignedEquipment: widget.assignedEquipment, + ), + ); + + if (result == null) { + return; + } + + _applyAiProposal(result); + } + + void _applyAiProposal(AiProposalResult result) { + final existingById = { + for (final equipment in widget.assignedEquipment) + equipment.equipmentId: equipment, + }; + + final updatedEquipment = result.equipment.map((proposed) { + final existing = existingById[proposed.equipmentId]; + if (existing == null) { + return proposed; + } + return existing.copyWith(quantity: proposed.quantity); + }).toList(); + + final updatedContainers = [...widget.assignedContainers]; + for (final containerId in result.containerIds) { + if (!updatedContainers.contains(containerId)) { + updatedContainers.add(containerId); + } + } + + widget.onChanged(updatedEquipment, updatedContainers); + } + void _removeEquipment(String equipmentId) { final updated = widget.assignedEquipment .where((eq) => eq.equipmentId != equipmentId) @@ -231,9 +297,8 @@ class _EventAssignedEquipmentSectionState extends State id != containerId) - .toList(); + final updatedContainers = + widget.assignedContainers.where((id) => id != containerId).toList(); // 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container final updatedEquipment = []; @@ -252,8 +317,10 @@ class _EventAssignedEquipmentSectionState extends State eq.equipmentId).toSet(); + final remainingEquipmentIds = + updatedEquipment.map((eq) => eq.equipmentId).toSet(); for (var equipmentId in container.equipmentIds) { if (!remainingEquipmentIds.contains(equipmentId)) { _equipmentCache.remove(equipmentId); @@ -301,7 +369,8 @@ class _EventAssignedEquipmentSectionState extends State Date: Mon, 30 Mar 2026 12:32:33 +0200 Subject: [PATCH 2/4] ### 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 { ], ), ), - ), + ), ); } From 7fc28f4374558543e754408ac6a4a596ffde99a4 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Mon, 25 May 2026 20:33:59 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20(BETA)=20Am=C3=A9lioration=20de=20l?= =?UTF-8?q?'assistant=20IA=20logisticien=20(Gemini)=20et=20support=20des?= =?UTF-8?q?=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Amélioration de l'IA (Cloud Functions)** : - Mise à jour du modèle vers `gemini-3.1-flash-lite` et augmentation de la limite des résultats de recherche à 50. - Optimisation de la gestion des outils : augmentation du nombre d'appels simultanés (`MAX_TOOL_CALLS_PER_ITERATION`) à 40. - Refonte du système de recherche d'équipements avec une stratégie en deux passes (recherche précise puis catégorielle avec normalisation agressive). - Nouvelles consignes strictes pour la gestion des unités uniques (quantité de 1 par ID) et priorité aux flight cases (containers). - Ajout d'une gestion de retry avec temporisation pour les erreurs de quota (429) et de surcharge (503). - Support de l'analyse de documents joints (devis, listes) envoyés en `inlineData`. - **Interface de l'Assistant (`AiEquipmentAssistantDialog`)** : - Ajout de la possibilité de joindre des documents (PDF, images, texte) via `FilePicker` pour analyse par l'IA. - Implémentation d'une vue de logs de debug détaillée pour suivre le raisonnement de l'IA et les appels d'outils. - Amélioration visuelle de la discussion : bulles de message stylisées et structuration automatique des réponses (sections "Matériel ajouté" vs "Matériel non trouvé"). - Nouvelles options de confirmation : "Tout ajouter" ou "Ajouter sans alternatives". - **Modèles et Services** : - Mise à jour de `EventEquipment` pour inclure un champ `rationale` (justification du choix de l'équipement). - Correction dans `EventAssignedEquipmentSection` pour ajouter automatiquement les équipements enfants lors de l'ajout d'un container proposé par l'IA. - Ajout de la gestion des logs et des documents dans `AiEquipmentAssistantService`. - **UI Divers** : - Mise à jour de `EquipmentFormPage` pour clarifier le comportement de l'identifiant (auto-génération recommandée). --- em2rp/functions/aiEquipmentProposal.js | 317 ++++++++++---- em2rp/lib/models/event_model.dart | 6 + .../ai_equipment_assistant_service.dart | 34 +- em2rp/lib/views/equipment_form_page.dart | 6 +- .../ai_equipment_assistant_dialog.dart | 401 ++++++++++++++++-- .../event_assigned_equipment_section.dart | 25 +- 6 files changed, 668 insertions(+), 121 deletions(-) diff --git a/em2rp/functions/aiEquipmentProposal.js b/em2rp/functions/aiEquipmentProposal.js index 46f06ee..aae337f 100644 --- a/em2rp/functions/aiEquipmentProposal.js +++ b/em2rp/functions/aiEquipmentProposal.js @@ -9,13 +9,13 @@ const { GoogleGenerativeAI } = require('@google/generative-ai'); const admin = require('firebase-admin'); const logger = require('firebase-functions/logger'); -const GEMINI_MODEL = 'gemini-3.1-flash-lite-preview'; +const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de versio, celle ci existe const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc'; const MAX_TOOL_ITERATIONS = 20; const PAST_EVENTS_LIMIT = 5; -const SEARCH_RESULTS_LIMIT = 20; +const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle const EVENT_SEARCH_SCAN_LIMIT = 100; -const MAX_TOOL_CALLS_PER_ITERATION = 5; +const MAX_TOOL_CALLS_PER_ITERATION = 40; const AVAILABILITY_EVENTS_SCAN_LIMIT = 500; const MAX_BATCH_AVAILABILITY_ITEMS = 50; @@ -25,7 +25,6 @@ const getDb = () => admin.firestore(); // ============================================================================ // Déclarations des tools Gemini // ============================================================================ - const AI_TOOLS = [ { functionDeclarations: [ @@ -167,33 +166,58 @@ const AI_TOOLS = [ const SYSTEM_PROMPT = `Tu es un expert logisticien IA pour la gestion de materiel evenementiel. Tu dois proposer une liste de materiel et de flight cases adaptee a l evenement decrit. -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 UNITES et QUANTITES (CRITIQUE) : +- La plupart des equipements (Lumiere, Son, Video, Structure) sont des UNITES UNIQUES. Chaque ID (ex: "BARRE_LED_#1") represente un seul appareil physique. +- Si l'utilisateur demande 6 barres LED, tu dois trouver et ajouter 6 IDs DIFFERENTS (ex: "BARRE_LED_#1", "BARRE_LED_#2", etc.) dans proposal.items, chacun avec "quantity": 1. +- Ne jamais mettre "quantity": 6 pour un item dont totalQuantity est 1 ou null. +- Les seules exceptions sont les categories "CONSUMABLE" (Consommable) et "CABLE" (Cable) qui peuvent avoir une quantite > 1 pour un seul ID. -Regles sur les flight cases (PRIORITAIRES) : -- Apres avoir identifie les equipements necessaires, appelle TOUJOURS search_containers avec la liste de leurs IDs. -- Si un flight case contient tous les equipements necessaires, propose le flight case uniquement. Ne mets PAS ces equipements dans items. -- Si un flight case contient plus d equipements que necessaire (ex: 5 blinders dans le case mais on en veut 2), propose les equipements individuellement, pas le flight case. -- N ajoute du materiel individuel dans items que pour les equipements sans flight case couvrant exactement le besoin. -- Si un equipement est indisponible, cherche une alternative via search_equipment, verifie sa dispo, puis search_containers sur l alternative. -- Verifie la disponibilite de CHAQUE equipement ET container propose. -- Quand une proposition courante existe deja, renvoie toujours la LISTE FINALE COMPLETE. +Regles sur les flight cases (PRIORITAIRE) : +- Les flight cases (containers) sont le moyen privilege de transporter le materiel. +- Strategie : + 1. Identifie d'abord tous les equipements individuels necessaires. + 2. Appelle search_containers avec la liste de TOUS ces IDs d'equipements. + 3. Si un ou plusieurs flight cases couvrent une partie ou la totalite du materiel : + - Ajoute les flight cases dans "proposal.containers". + - RETIRE les equipements contenus dans ces flight cases de ta liste "proposal.items". + - Les equipements restants (non contenus dans des flight cases) doivent rester dans "proposal.items". +- Un flight case est toujours preferable a des items individuels. + +Regles de recherche (STRATEGIE EN DEUX PASSES) : +1. PREMIER PASSE (RECHERCHE PRECISE) : + - Cherche chaque item demande avec son nom complet (ex: "dB Technologies Ignenia IG5TR"). + - Si ca ne donne rien, REESSAYE immediatement avec des mots-clefs plus courts (ex: "Ignenia", "IG5TR", "VIO S118"). + - Sois perseverant : les noms en base sont souvent au format "MARQUE_MODELE_#1". + +2. DEUXIEME PASSE (ANALYSE CONTEXTUELLE ET CATEGORIELLE) : + - Si la recherche precise echoue, analyse le contexte de l'item dans le devis ou la demande. + - Identifie la categorie probable (ex: "dB Technologies" -> "SONO" ou "SOUND", "Robert Juliat" -> "LUMIERE" ou "LIGHTING"). + - Utilise list_equipment_by_category pour recuperer tout le materiel de cette categorie. + - Parcoure la liste recuperee pour trouver un item qui "match" logiquement (ex: l'utilisateur demande "Opera 15" mais l'identifiant est "DBTE_OPERA_15_#1"). + - Si tu trouves une correspondance logique dans la liste de la categorie, utilise l'ID exact trouve en base. + + Regles de transparence (OBLIGATOIRE) : + - Ton assistantMessage doit TOUJOURS contenir deux sections claires et distinctes si applicable : + 1. "Matériel ajouté :" (Utilise exactement ce titre) suivi de la liste des équipements et flight cases que tu as réussi à trouver et qui sont disponibles. Explique brièvement pourquoi. + 2. "Matériel non trouvé ou indisponible :" (Utilise exactement ce titre) suivi de la liste précise des éléments de la demande utilisateur que tu n'as pas pu intégrer. Pour chaque élément, précise s'il est "Absent de la base de données" ou "Indisponible (rupture de stock)". + - N'hésite pas à être pédagogue dans tes explications, mais garde ces titres de section exacts pour permettre un affichage structuré dans l'application. + + Regles absolues : + - Tu ne dois JAMAIS ecrire en base de donnees. + - Si l utilisateur cite un evenement precis (nom/date), appelle d abord search_event_reference. + - Rend toujours la LISTE FINALE COMPLETE y compris le materiel deja assigne s'il est toujours pertinent. + - Verifie la disponibilite de CHAQUE equipement ET container propose via check_availability_batch. + - Si un materiel est en rupture de stock, cherche une alternative (autre ID du meme modele ou autre modele similaire). Si aucune alternative n'est trouvee, deplace l'item dans la section "indisponible". Reponse finale : du JSON valide strict, sans markdown, avec ce format exact : {"assistantMessage":"...","proposal":{"summary":"...","containers":[{"containerId":"ID-EXACT","rationale":"..."}],"items":[{"equipmentId":"ID-EXACT","quantity":1,"rationale":"..."}]}} -- containers : flight cases proposes (les equipements couverts par un container ne doivent pas etre dans items) -- items : equipements individuels non couverts par un container +- containers : flight cases proposes. +- items : equipements individuels NON contenus dans les flight cases proposes ci-dessus. - Si impossible, renvoie proposal a null.`; // ============================================================================ // Implémentation des tools côté serveur (Firestore Admin SDK) // ============================================================================ - /** * Recherche des équipements dans Firestore par mot-clé. */ @@ -202,26 +226,37 @@ async function toolSearchEquipment(query) { return { query, count: 0, results: [] }; } - const normalizedQuery = query.trim().toLowerCase(); + // Normalisation agressive pour la recherche (sans accents, sans ponctuation) + const normalize = (str) => + (str || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/g, ''); - const snapshot = await getDb().collection('equipments').limit(200).get(); + const normalizedQuery = normalize(query); + + const snapshot = await getDb().collection('equipments').limit(500).get(); const results = []; snapshot.docs.forEach((doc) => { const data = doc.data(); - const searchableText = [ + const searchableFields = [ data.name, doc.id, data.model, data.brand, data.category, data.subCategory, - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); + ]; - if (searchableText.includes(normalizedQuery)) { + const isMatch = searchableFields.some(field => { + if (!field) return false; + const normalizedField = normalize(String(field)); + return normalizedField.includes(normalizedQuery) || normalizedQuery.includes(normalizedField); + }); + + if (isMatch) { results.push({ id: doc.id, name: data.name || doc.id, @@ -837,28 +872,35 @@ async function toolSearchEventReference(query, dateHint) { async function toolListEquipmentByCategory(category, subCategory) { let queryDb = getDb().collection('equipments'); - if (category) { - queryDb = queryDb.where('category', '==', category); - } + // Si on a des critères précis, on filtre. Sinon on scanne pour matcher flou. + const snapshot = await queryDb.limit(1000).get(); - if (subCategory) { - queryDb = queryDb.where('subCategory', '==', subCategory); - } + const normalize = (str) => + (str || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim(); - const snapshot = await queryDb.limit(50).get(); + const normCat = category ? normalize(category) : null; + const normSub = subCategory ? normalize(subCategory) : null; - 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 || '', - }; - }); + 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 || '', + }; + }) + .filter(item => { + let match = true; + if (normCat) match = match && normalize(item.category).includes(normCat); + if (normSub) match = match && normalize(item.subCategory).includes(normSub); + return match; + }) + .slice(0, 50); return { category: category || 'all', @@ -922,7 +964,6 @@ async function executeTool(toolCall, excludeEventId, sharedContext) { // ============================================================================ // Gestionnaire principal // ============================================================================ - /** * Construit le prompt utilisateur avec le contexte de l'événement. */ @@ -965,6 +1006,7 @@ function buildUserPrompt({ 'Demande :', finalMessage, '', + 'Si un fichier (document/devis) a été joint à ma demande (sous forme inlineData by Gemini), tu dois absolument l analyser, en deduire le materiel demande, et l ajouter a ta proposition.', 'Si la demande cite un evenement precis (nom/date), commence par search_event_reference avant de proposer du materiel.', 'Si une proposition courante existe, traite-la comme base de travail et renvoie toujours la liste finale complete apres modification.', 'Rappel : vérifier la disponibilité avant de recommander. Privilégier check_availability_batch pour contrôler plusieurs équipements en un appel.', @@ -972,19 +1014,56 @@ function buildUserPrompt({ ].join('\n'); } +/** + * Construit un tableau de parts pour Gemini permettant d'inclure des documents. + */ +function buildMessageParts(params, logDebug) { + const textPrompt = buildUserPrompt(params); + const parts = [{ text: textPrompt }]; + + if (params.document && params.document.data && params.document.mimeType) { + if (logDebug) logDebug(`[AI] Attaching document ${params.document.mimeType}`); + else logger.info(`[AI] Attaching document ${params.document.mimeType}`); + parts.push({ + inlineData: { + data: params.document.data, + mimeType: params.document.mimeType, + } + }); + const fileNameInfo = params.document.fileName ? ` (nom du fichier: ${params.document.fileName})` : ``; + parts.push({ + text: `J'ai joint un document de type ${params.document.mimeType}${fileNameInfo}. Merci d'extraire le materiel et de l'ajouter a la proposition.`, + }); + } + + return parts; +} + /** * Extrait un texte exploitable depuis la réponse Gemini, même si response.text() est vide. */ -function extractResponseText(modelResponse) { - if (!modelResponse) return ''; +function extractResponseText(modelResponse, logDebug, logError) { + const info = logDebug || logger.info.bind(logger); + const warn = logError || logger.warn.bind(logger); + + if (!modelResponse) { + warn('[AI] extractResponseText: modelResponse est vide ou undefined'); + return ''; + } + + info('[AI] extractResponseText: analyse de la réponse brut', { + hasTextFunc: typeof modelResponse.text === 'function', + candidatesCount: modelResponse.candidates?.length || 0 + }); try { const directText = modelResponse.text?.(); if (directText && directText.trim().length > 0) { + info('[AI] extractResponseText: Texte extrait via modelResponse.text()'); return directText.trim(); } - } catch (_) { - // Fallback via candidates/parts ci-dessous. + } catch (err) { + warn('[AI] extractResponseText: erreur lors de l appel a modelResponse.text()', err?.message); } const candidates = Array.isArray(modelResponse.candidates) ? modelResponse.candidates : []; @@ -1001,17 +1080,24 @@ function extractResponseText(modelResponse) { } } - return textParts.join('\n').trim(); + const fallbackText = textParts.join('\n').trim(); + info(`[AI] extractResponseText: Texte extrait en fallback (longueur: ${fallbackText.length})`); + return fallbackText; } /** * Extrait et parse le JSON de la réponse IA. */ -function parseAiResponse(rawText) { +function parseAiResponse(rawText, logDebug, logError) { + const info = logDebug || logger.info.bind(logger); + const err = logError || logger.error.bind(logger); + if (!rawText || rawText.trim().length === 0) { + err('[AI] parseAiResponse: rawText recu est vide !'); throw new Error('Réponse IA vide.'); } + info('[AI] parseAiResponse tentatives de parsing sur le texte (apercu):', rawText.substring(0, 100) + '...'); // Tentative directe try { const parsed = JSON.parse(rawText.trim()); @@ -1045,10 +1131,55 @@ function parseAiResponse(rawText) { throw new Error('JSON IA invalide ou introuvable dans la réponse.'); } +/** + * Envoie un message à Gemini avec une temporisation en cas de Rate Limit (429). + */ +async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { + const maxRetries = 5; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await chat.sendMessage(messagePayload); + } catch (error) { + const status = error?.status || error?.response?.status; + const errorMessage = error.message || ''; + const isRateLimit = status === 429 || errorMessage.includes('429') || errorMessage.includes('quota'); + const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand'); + + if ((isRateLimit || isOverloaded) && attempt < maxRetries) { + let delayMs = isOverloaded ? 5000 : 30000; // Plus court pour 503, long pour 429 + + const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i); + if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) { + delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000; + } + + const reason = isOverloaded ? 'Modèle surchargé (503)' : 'Quota atteint (429)'; + logDebug(`[AI] ${reason}. Temporisation de ${delayMs / 1000}s avant la tentative ${attempt + 1}/${maxRetries}...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } else { + throw error; + } + } + } +} + /** * Handler principal de la Cloud Function aiEquipmentProposal. */ async function handleAiEquipmentProposal(req, res) { + const debugLogs = []; + const logDebug = (msg, data) => { + const dataStr = data ? ` | ${JSON.stringify(data)}` : ''; + debugLogs.push(`[INFO] ${msg}${dataStr}`); + logger.info(msg, data); + }; + const logError = (msg, data) => { + const dataStr = data ? ` | ${JSON.stringify(data)}` : ''; + debugLogs.push(`[ERROR] ${msg}${dataStr}`); + logger.error(msg, data); + }; + const { eventName, eventTypeId, @@ -1062,6 +1193,7 @@ async function handleAiEquipmentProposal(req, res) { currentEquipment = [], workingProposal = [], excludeEventId, + document, } = req.body.data || {}; if (!startDate || !endDate) { @@ -1075,9 +1207,11 @@ async function handleAiEquipmentProposal(req, res) { systemInstruction: SYSTEM_PROMPT, tools: AI_TOOLS, toolConfig: { functionCallingConfig: { mode: 'AUTO' } }, - generationConfig: { temperature: 0.2, responseMimeType: 'application/json' }, + generationConfig: { temperature: 0.2 }, }); + logDebug('[AI] Reconstruction de l historique de conversation. Elements :', history?.length || 0); + // Reconstruire l'historique de conversation const chatHistory = (history || []) .filter((turn) => turn.text && turn.text.trim().length > 0) @@ -1086,13 +1220,15 @@ async function handleAiEquipmentProposal(req, res) { parts: [{ text: turn.text.trim() }], })); + logDebug('[AI] Historique formaté :', chatHistory); + const chat = model.startChat({ history: chatHistory }); const toolResultCache = new Map(); const sharedToolContext = { availabilityCandidatesByWindow: new Map(), }; - const userPrompt = buildUserPrompt({ + const messageParts = buildMessageParts({ userMessage, eventName, eventTypeId, @@ -1103,25 +1239,38 @@ async function handleAiEquipmentProposal(req, res) { eventOptions, currentEquipment, workingProposal, + document, + }, logDebug); + + // Sécuriser les logs de base64 + const safeMessagePartsForLogs = JSON.parse(JSON.stringify(messageParts)); + safeMessagePartsForLogs.forEach(part => { + if (part.inlineData && part.inlineData.data) { + part.inlineData.data = ``; + } }); - logger.info('[AI] Starting conversation', { eventTypeId, startDate, endDate }); + logDebug('[AI] Starting conversation. Parts envoys:', safeMessagePartsForLogs); let response; try { - response = await chat.sendMessage(userPrompt); + response = await sendMessageWithRetry(chat, messageParts, logDebug, logError); + logDebug('[AI] Premiere reponse recue du sendMessage.'); // Boucle de function calling avec cache local. for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { const functionCalls = response.response.functionCalls(); if (!functionCalls || functionCalls.length === 0) { + logDebug(`[AI] (Iteration ${iteration + 1}) Fin des tool calls, l IA a retourné une réponse (ou un appel vide).`); break; } - const limitedCalls = functionCalls.slice(0, MAX_TOOL_CALLS_PER_ITERATION); - const availabilityCalls = limitedCalls.filter( + logDebug(`[AI] (Iteration ${iteration + 1}) Tool calls demands par l IA:`, functionCalls); + + const callsToProcess = functionCalls.slice(0, MAX_TOOL_CALLS_PER_ITERATION); + const availabilityCalls = callsToProcess.filter( (call) => call.name === 'check_availability' && call.args?.equipmentId && call.args?.startDate @@ -1164,7 +1313,7 @@ async function handleAiEquipmentProposal(req, res) { batchResult.results.map((item) => [String(item.equipmentId), item]), ); batchWindow = { startDate: firstStartDate, endDate: firstEndDate }; - logger.info('[AI] Consolidated check_availability calls into one batch call', { + logDebug('[AI] Consolidated check_availability calls into one batch call', { iteration: iteration + 1, itemCount: equipmentIds.length, }); @@ -1172,10 +1321,19 @@ async function handleAiEquipmentProposal(req, res) { } } - logger.info(`[AI] Tool calls (iteration ${iteration + 1}):`, limitedCalls.map((c) => c.name)); + logDebug(`[AI] Tool calls executes. Preparation du renvoi des resultats...`); const toolResults = await Promise.all( - limitedCalls.map(async (toolCall) => { + functionCalls.map(async (toolCall, ind) => { + if (ind >= MAX_TOOL_CALLS_PER_ITERATION) { + return { + functionResponse: { + name: toolCall.name, + response: { error: 'Limite de tool calls simultanes atteinte. (Ignore)' }, + }, + }; + } + const cacheKey = buildToolCacheKey(toolCall.name, toolCall.args); if (toolResultCache.has(cacheKey)) { return { @@ -1205,7 +1363,7 @@ async function handleAiEquipmentProposal(req, res) { toolResult = await executeTool(toolCall, excludeEventId, sharedToolContext); } } catch (toolError) { - logger.warn('[AI] Tool call failed, returning degraded tool response', { + logError('[AI] Tool call failed, returning degraded tool response', { tool: toolCall.name, message: toolError?.message || 'unknown', }); @@ -1214,6 +1372,8 @@ async function handleAiEquipmentProposal(req, res) { toolResultCache.set(cacheKey, toolResult); + logDebug(`[AI] Resultat du tool ${toolCall.name} (apercu):`, JSON.stringify(toolResult).substring(0, 150) + '...'); + return { functionResponse: { name: toolCall.name, @@ -1223,24 +1383,30 @@ async function handleAiEquipmentProposal(req, res) { }), ); - response = await chat.sendMessage(toolResults); + logDebug(`[AI] Envoi de ${toolResults.length} resultats au modèle pour la suite de la conversation.`); + response = await sendMessageWithRetry(chat, toolResults, logDebug, logError); + logDebug(`[AI] Reponse recue du modele suite aux resultats des tools.`); } } catch (error) { - logger.error('[AI] Conversation timeout/error', { + logError('[AI] Conversation timeout/error, DETAIL COMPLET:', { message: error?.message || 'unknown', + status: error?.status, + details: error?.details, stack: error?.stack }); res.status(200).json({ 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, + debugLogs, }); return; } - const rawText = extractResponseText(response.response); - logger.info('[AI] Raw response received, parsing...', { + logDebug('[AI] Structure finale du response.response (apercu):', JSON.stringify(response.response).substring(0, 300) + '...'); + const rawText = extractResponseText(response.response, logDebug, logError); + logDebug('[AI] Raw response extracted:', { hasText: rawText.length > 0, - candidateCount: response.response?.candidates?.length || 0, + rawTextExcerpt: rawText.substring(0, 200) + '...' }); // Fallback non bloquant: éviter un 500 quand Gemini ne renvoie pas de texte exploitable. @@ -1248,18 +1414,20 @@ async function handleAiEquipmentProposal(req, res) { res.status(200).json({ assistantMessage: 'Je n\'ai pas pu générer une réponse exploitable pour le moment. Réessaie avec une consigne plus précise.', proposal: null, + debugLogs, }); return; } let payload; try { - payload = parseAiResponse(rawText); + payload = parseAiResponse(rawText, logDebug, logError); } catch (error) { - logger.error('[AI] JSON parsing failed, returning degraded response', error); + logError('[AI] JSON parsing failed, returning degraded response', error?.message); res.status(200).json({ assistantMessage: rawText, proposal: null, + debugLogs, }); return; } @@ -1296,8 +1464,7 @@ async function handleAiEquipmentProposal(req, res) { } } - res.status(200).json({ assistantMessage, proposal }); + res.status(200).json({ assistantMessage, proposal, debugLogs }); } module.exports = { handleAiEquipmentProposal }; - diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index 87f5171..31ff8ad 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) { class EventEquipment { final String equipmentId; // ID de l'équipement final int quantity; // Quantité initiale assignée + final String? rationale; // Explication/Justification (ex: IA alternative) final bool isPrepared; // Validé en préparation final bool isLoaded; // Validé au chargement final bool isUnloaded; // Validé au déchargement @@ -194,6 +195,7 @@ class EventEquipment { EventEquipment({ required this.equipmentId, this.quantity = 1, + this.rationale, this.isPrepared = false, this.isLoaded = false, this.isUnloaded = false, @@ -212,6 +214,7 @@ class EventEquipment { return EventEquipment( equipmentId: map['equipmentId'] ?? '', quantity: map['quantity'] ?? 1, + rationale: map['rationale'], isPrepared: map['isPrepared'] ?? false, isLoaded: map['isLoaded'] ?? false, isUnloaded: map['isUnloaded'] ?? false, @@ -231,6 +234,7 @@ class EventEquipment { return { 'equipmentId': equipmentId, 'quantity': quantity, + 'rationale': rationale, 'isPrepared': isPrepared, 'isLoaded': isLoaded, 'isUnloaded': isUnloaded, @@ -249,6 +253,7 @@ class EventEquipment { EventEquipment copyWith({ String? equipmentId, int? quantity, + String? rationale, bool? isPrepared, bool? isLoaded, bool? isUnloaded, @@ -265,6 +270,7 @@ class EventEquipment { return EventEquipment( equipmentId: equipmentId ?? this.equipmentId, quantity: quantity ?? this.quantity, + rationale: rationale ?? this.rationale, isPrepared: isPrepared ?? this.isPrepared, isLoaded: isLoaded ?? this.isLoaded, isUnloaded: isUnloaded ?? this.isUnloaded, diff --git a/em2rp/lib/services/ai_equipment_assistant_service.dart b/em2rp/lib/services/ai_equipment_assistant_service.dart index 9677ebe..345ccbe 100644 --- a/em2rp/lib/services/ai_equipment_assistant_service.dart +++ b/em2rp/lib/services/ai_equipment_assistant_service.dart @@ -12,6 +12,19 @@ class AiAssistantChatTurn { 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; @@ -48,10 +61,12 @@ class AiEquipmentProposal { class AiEquipmentAssistantResponse { final String assistantMessage; final AiEquipmentProposal? proposal; + final List debugLogs; const AiEquipmentAssistantResponse({ required this.assistantMessage, this.proposal, + this.debugLogs = const [], }); } @@ -74,6 +89,7 @@ class AiEquipmentAssistantService { String? excludeEventId, List currentAssignedEquipment = const [], List workingProposalEquipment = const [], + AiEquipmentDocument? document, }) async { final payload = { 'startDate': startDate.toIso8601String(), @@ -94,12 +110,23 @@ class AiEquipmentAssistantService { 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}', @@ -110,6 +137,7 @@ class AiEquipmentAssistantService { ? 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); @@ -148,7 +176,11 @@ class AiEquipmentAssistantService { quantity: quantity, rationale: rationale, )); - eventEquipmentList.add(EventEquipment(equipmentId: equipmentId, quantity: quantity)); + eventEquipmentList.add(EventEquipment( + equipmentId: equipmentId, + quantity: quantity, + rationale: rationale, + )); } } diff --git a/em2rp/lib/views/equipment_form_page.dart b/em2rp/lib/views/equipment_form_page.dart index c4c1cf2..7c0b7bb 100644 --- a/em2rp/lib/views/equipment_form_page.dart +++ b/em2rp/lib/views/equipment_form_page.dart @@ -163,11 +163,11 @@ class _EquipmentFormPageState extends State { TextFormField( controller: _identifierController, decoration: InputDecoration( - labelText: 'Identifiant *', + labelText: 'Identifiant (Laissez vide pour auto-génération) *', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.tag), - hintText: isEditing ? null : 'Laissez vide pour générer automatiquement', - helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}', + hintText: isEditing ? null : 'Auto-attribué par défaut', + helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle', ), enabled: !isEditing, validator: (value) { diff --git a/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart index 9db9782..a5d6f21 100644 --- a/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart +++ b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart @@ -1,6 +1,9 @@ import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/services/ai_equipment_assistant_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:file_picker/file_picker.dart'; +import 'dart:convert'; /// Résultat retourné par le dialog après confirmation de la proposition IA. class AiProposalResult { @@ -47,6 +50,8 @@ class _AiEquipmentAssistantDialogState String? _errorMessage; AiEquipmentProposal? _latestProposal; late List _workingEquipment; + AiEquipmentDocument? _selectedDocument; + List _sessionLogs = []; @override void initState() { @@ -90,12 +95,17 @@ class _AiEquipmentAssistantDialogState setState(() { _errorMessage = null; _messages.add(_AssistantChatMessage.user(userMessage)); + if (_selectedDocument != null) { + _messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]')); + } _isLoading = true; }); _scrollToBottom(); try { + final documentToSend = _selectedDocument; + _selectedDocument = null; // Clear after sending final response = await _assistantService .generateProposal( startDate: widget.startDate, @@ -105,6 +115,7 @@ class _AiEquipmentAssistantDialogState currentAssignedEquipment: widget.currentAssignedEquipment, workingProposalEquipment: _workingEquipment, userMessage: userMessage, + document: documentToSend, history: _messages .map((message) => AiAssistantChatTurn( isUser: message.isUser, text: message.text)) @@ -124,6 +135,7 @@ class _AiEquipmentAssistantDialogState response.proposal!.asEventEquipment, ); } + _sessionLogs.addAll(response.debugLogs); _isLoading = false; }); _scrollToBottom(); @@ -159,6 +171,91 @@ class _AiEquipmentAssistantDialogState }); } + Future _pickDocument() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'], + withData: true, + ); + + if (result != null && result.files.isNotEmpty) { + final file = result.files.first; + if (file.bytes != null) { + final base64String = base64Encode(file.bytes!); + String mimeType = 'application/octet-stream'; + if (file.extension == 'pdf') mimeType = 'application/pdf'; + else if (file.extension == 'txt') mimeType = 'text/plain'; + else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg'; + else if (file.extension == 'png') mimeType = 'image/png'; + + setState(() { + _selectedDocument = AiEquipmentDocument( + base64Data: base64String, + mimeType: mimeType, + fileName: file.name, + ); + }); + } + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Erreur lors de la selection du document : $e'; + }); + } + } + } + + void _showLogsDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Logs de l\'IA'), + content: SizedBox( + width: 800, + height: 600, + child: ListView.builder( + itemCount: _sessionLogs.length, + itemBuilder: (context, index) { + final log = _sessionLogs[index]; + final isError = log.startsWith('[ERROR]'); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + log, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: isError ? Colors.red : Colors.black87, + ), + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + final fullLogs = _sessionLogs.join('\n'); + Clipboard.setData(ClipboardData(text: fullLogs)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Logs copiés dans le presse-papiers')), + ); + }, + child: const Text('Copier tout'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ); + }, + ); + } + @override Widget build(BuildContext context) { return Dialog( @@ -172,6 +269,12 @@ class _AiEquipmentAssistantDialogState automaticallyImplyLeading: false, title: const Text('Assistant IA Logisticien'), actions: [ + if (_sessionLogs.isNotEmpty) + IconButton( + icon: const Icon(Icons.bug_report), + tooltip: 'Voir les logs', + onPressed: _showLogsDialog, + ), IconButton( icon: const Icon(Icons.close), onPressed: @@ -208,8 +311,12 @@ class _AiEquipmentAssistantDialogState child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 12), - Text( - 'Generation en cours... verification du materiel et disponibilites.'), + Expanded( + child: const Text( + 'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)', + textAlign: TextAlign.left, + ), + ), ], ), ), @@ -230,10 +337,42 @@ class _AiEquipmentAssistantDialogState ), if (_latestProposal != null) _buildProposalSummary(_latestProposal!), + if (_selectedDocument != null) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8), + child: Row( + children: [ + const Icon(Icons.attach_file, color: Colors.blue, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _selectedDocument!.fileName ?? 'Document joint', + style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () { + setState(() { + _selectedDocument = null; + }); + }, + tooltip: 'Retirer le document', + ), + ], + ), + ), Padding( padding: const EdgeInsets.all(16), child: Row( children: [ + IconButton( + icon: const Icon(Icons.attach_file), + onPressed: _isLoading ? null : _pickDocument, + tooltip: 'Joindre un devis ou document', + ), Expanded( child: TextField( controller: _messageController, @@ -274,17 +413,140 @@ class _AiEquipmentAssistantDialogState child: Container( margin: const EdgeInsets.only(bottom: 10), constraints: const BoxConstraints(maxWidth: 560), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: bubbleColor, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(message.isUser ? 16 : 4), + bottomRight: Radius.circular(message.isUser ? 4 : 16), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], border: - message.isUser ? null : Border.all(color: Colors.grey.shade300), - ), - child: Text( - message.text, - style: TextStyle(color: textColor), + message.isUser ? null : Border.all(color: Colors.grey.shade200), ), + child: message.isUser + ? Text(message.text, style: TextStyle(color: textColor)) + : _buildAssistantMessageContent(message.text), + ), + ); + } + + Widget _buildAssistantMessageContent(String text) { + // Si le message semble structuré par l'IA avec nos nouvelles règles + if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) { + final sections = text.split('\n\n'); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sections.map((section) { + final isAdded = section.contains('Matériel ajouté :'); + final isMissing = section.contains('Matériel non trouvé'); + + if (isAdded) { + return _buildStatusSection( + title: section.split('\n').first, + content: section.split('\n').skip(1).join('\n'), + icon: Icons.check_circle_outline, + color: Colors.green.shade700, + bgColor: Colors.green.shade50, + ); + } else if (isMissing) { + return _buildStatusSection( + title: section.split('\n').first, + content: section.split('\n').skip(1).join('\n'), + icon: Icons.warning_amber_rounded, + color: Colors.orange.shade800, + bgColor: Colors.orange.shade50, + ); + } + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(section), + ); + }).toList(), + ); + } + + return Text(text); + } + + Widget _buildStatusSection({ + required String title, + required String content, + required IconData icon, + required Color color, + required Color bgColor, + }) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Text( + title.replaceAll(':', '').trim(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + if (content.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + content.trim(), + style: TextStyle(fontSize: 13, color: Colors.grey.shade800), + ), + ], + ], + ), + ); + } + + void _confirmProposal({bool excludeAlternatives = false}) { + if (_latestProposal == null) return; + + List equipment = List.from(_latestProposal!.asEventEquipment); + List containerIds = List.from(_latestProposal!.containerIds); + + if (excludeAlternatives) { + // On utilise la liste des items d'origine pour savoir lesquels exclure + // car ils contiennent le champ rationale (avant conversion en EventEquipment) + final idsToExclude = _latestProposal!.items + .where((item) { + final rationale = item.rationale.toLowerCase(); + return rationale.contains('alternative') || + rationale.contains('remplacement') || + rationale.contains('indisponible'); + }) + .map((item) => item.equipmentId) + .toSet(); + + equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList(); + } + + Navigator.of(context).pop( + AiProposalResult( + equipment: equipment, + containerIds: containerIds, ), ); } @@ -293,21 +555,38 @@ class _AiEquipmentAssistantDialogState return Container( width: double.infinity, margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(maxHeight: 240), + padding: const EdgeInsets.all(16), + constraints: const BoxConstraints(maxHeight: 280), decoration: BoxDecoration( - color: Colors.green.shade50, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.green.shade200), + color: Colors.indigo.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.indigo.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Recapitulatif propose', - style: TextStyle(fontWeight: FontWeight.bold), + Row( + children: [ + const Icon(Icons.assignment_turned_in, color: Colors.indigo), + const SizedBox(width: 12), + const Text( + 'Récapitulatif de la proposition IA', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.indigo, + ), + ), + ], ), - const SizedBox(height: 8), + const SizedBox(height: 12), Flexible( child: Scrollbar( controller: _proposalScrollController, @@ -319,28 +598,58 @@ class _AiEquipmentAssistantDialogState crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(proposal.summary), + Text( + proposal.summary, + style: const TextStyle(fontStyle: FontStyle.italic), + ), if (proposal.items.isNotEmpty) ...[ - const SizedBox(height: 8), + const SizedBox(height: 12), + const Text( + 'Matériel individuel :', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 4), ...proposal.items.map((item) { + final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement'); return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - '- ${item.equipmentId} x${item.quantity} - ${item.rationale}', + padding: const EdgeInsets.only(bottom: 6, left: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isAlt ? Icons.swap_horiz : Icons.add_circle_outline, + size: 14, + color: isAlt ? Colors.orange : Colors.indigo, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${item.equipmentId} x${item.quantity}', + style: const TextStyle(fontWeight: FontWeight.w500) + ), + ), + ], ), ); }), ], if (proposal.containerIds.isNotEmpty) ...[ - const SizedBox(height: 8), + const SizedBox(height: 12), const Text( - 'Boites proposees :', - style: TextStyle(fontWeight: FontWeight.w600), + 'Fly-cases & Boîtes :', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), ), + const SizedBox(height: 4), ...proposal.containerIds.map((id) { return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text('- $id'), + padding: const EdgeInsets.only(bottom: 6, left: 4), + child: Row( + children: [ + const Icon(Icons.inventory_2_outlined, size: 14, color: Colors.indigo), + const SizedBox(width: 8), + Text(id, style: const TextStyle(fontWeight: FontWeight.w500)), + ], + ), ); }), ], @@ -349,18 +658,30 @@ class _AiEquipmentAssistantDialogState ), ), ), - const SizedBox(height: 10), - ElevatedButton.icon( - onPressed: _isLoading - ? null - : () => Navigator.of(context).pop( - AiProposalResult( - equipment: proposal.asEventEquipment, - containerIds: proposal.containerIds, - ), - ), - icon: const Icon(Icons.add_task), - label: const Text('Confirmer et Ajouter'), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: _isLoading ? null : () => _confirmProposal(), + icon: const Icon(Icons.check), + label: const Text('Tout ajouter'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + ), + ), + OutlinedButton.icon( + onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true), + icon: const Icon(Icons.filter_list_off), + label: const Text('Ajouter sans alternatives'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.indigo, + side: const BorderSide(color: Colors.indigo), + ), + ), + ], ), ], ), diff --git a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart index 4da6663..50d691f 100644 --- a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart @@ -257,7 +257,7 @@ class _EventAssignedEquipmentSectionState _applyAiProposal(result); } - void _applyAiProposal(AiProposalResult result) { + void _applyAiProposal(AiProposalResult result) async { final existingById = { for (final equipment in widget.assignedEquipment) equipment.equipmentId: equipment, @@ -268,9 +268,30 @@ class _EventAssignedEquipmentSectionState if (existing == null) { return proposed; } - return existing.copyWith(quantity: proposed.quantity); + return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale); }).toList(); + // 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants + if (result.containerIds.isNotEmpty) { + final containerProvider = context.read(); + final containers = await containerProvider.getContainersByIds(result.containerIds); + + for (var container in containers) { + for (var childEquipmentId in container.equipmentIds) { + // Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition) + final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId); + if (!exists) { + updatedEquipment.add(EventEquipment( + equipmentId: childEquipmentId, + quantity: 1, + rationale: 'Inclus dans ${container.id}', + )); + DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}'); + } + } + } + } + final updatedContainers = [...widget.assignedContainers]; for (final containerId in result.containerIds) { if (!updatedContainers.contains(containerId)) { From 7258509528d2f7685c436b3df3ac871de6e8131f Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Mon, 25 May 2026 23:00:43 +0200 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20am=C3=A9lioration=20de=20l'assistan?= =?UTF-8?q?t=20IA=20logisticien=20et=20de=20la=20gestion=20des=20container?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **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. --- em2rp/.gitignore | 1 + em2rp/functions/aiEquipmentProposal.js | 428 ++++++++++++++++-- em2rp/functions/index.js | 7 +- em2rp/functions/package-lock.json | 14 +- em2rp/functions/package.json | 2 +- .../ai_equipment_assistant_service.dart | 86 +++- .../ai_equipment_assistant_dialog.dart | 112 +++-- 7 files changed, 563 insertions(+), 87 deletions(-) diff --git a/em2rp/.gitignore b/em2rp/.gitignore index cd098bd..0e816a0 100644 --- a/em2rp/.gitignore +++ b/em2rp/.gitignore @@ -45,3 +45,4 @@ app.*.map.json # Environment configuration with credentials lib/config/env.dev.dart functions/.env +functions/.env.local \ No newline at end of file diff --git a/em2rp/functions/aiEquipmentProposal.js b/em2rp/functions/aiEquipmentProposal.js index aae337f..b6c068c 100644 --- a/em2rp/functions/aiEquipmentProposal.js +++ b/em2rp/functions/aiEquipmentProposal.js @@ -9,8 +9,16 @@ const { GoogleGenerativeAI } = require('@google/generative-ai'); const admin = require('firebase-admin'); const logger = require('firebase-functions/logger'); -const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de versio, celle ci existe -const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc'; +const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de version, celle ci existe +const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY || ''; + +// Log de démarrage pour debug +if (GEMINI_API_KEY) { + const maskedKey = GEMINI_API_KEY.substring(0, 8) + '...' + GEMINI_API_KEY.substring(GEMINI_API_KEY.length - 5); + logger.info(`[AI] GEMINI_API_KEY chargée (masquée: ${maskedKey})`); +} else { + logger.warn('[AI] ⚠️ GEMINI_API_KEY non trouvée ! Vérifiez .env.local ou les variables d\'environnement Firebase'); +} const MAX_TOOL_ITERATIONS = 20; const PAST_EVENTS_LIMIT = 5; const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle @@ -140,6 +148,32 @@ const AI_TOOLS = [ required: ['equipmentIds'], }, }, + { + name: 'check_container_availability', + description: 'Vérifie si un container (flight case) et son contenu sont disponibles pour une période donnée.', + parameters: { + type: 'object', + properties: { + containerId: { type: 'string', description: 'ID exact du container à vérifier.' }, + startDate: { type: 'string', description: 'Date de début ISO-8601.' }, + endDate: { type: 'string', description: 'Date de fin ISO-8601.' }, + }, + required: ['containerId', 'startDate', 'endDate'], + }, + }, + { + name: 'check_container_availability_batch', + description: 'Vérifie la disponibilité d une liste de containers pour la même période en un seul appel.', + parameters: { + type: 'object', + properties: { + containerIds: { type: 'array', items: { type: 'string' }, description: 'Liste des IDs de containers (max 50).' }, + startDate: { type: 'string', description: 'Date de début ISO-8601.' }, + endDate: { type: 'string', description: 'Date de fin ISO-8601.' }, + }, + required: ['containerIds', 'startDate', 'endDate'], + }, + }, { 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.', @@ -226,50 +260,96 @@ async function toolSearchEquipment(query) { return { query, count: 0, results: [] }; } - // Normalisation agressive pour la recherche (sans accents, sans ponctuation) + // Normalisation pour comparer proprement const normalize = (str) => (str || '') + .toString() .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]/g, ''); + .replace(/[^a-z0-9\s-]/g, '') + .trim(); const normalizedQuery = normalize(query); + const tokens = extractQueryTokens(query); + // Charger un snapshot raisonnable une seule fois const snapshot = await getDb().collection('equipments').limit(500).get(); + + const docs = snapshot.docs.map((doc) => { + const data = doc.data(); + return { + id: doc.id, + name: String(data.name || doc.id), + category: data.category || '', + subCategory: data.subCategory || '', + brand: data.brand || null, + model: data.model || null, + status: data.status || '', + availableQuantity: data.availableQuantity ?? null, + totalQuantity: data.totalQuantity ?? null, + }; + }); + const results = []; - snapshot.docs.forEach((doc) => { - const data = doc.data(); - const searchableFields = [ - data.name, - doc.id, - data.model, - data.brand, - data.category, - data.subCategory, - ]; - - const isMatch = searchableFields.some(field => { - if (!field) return false; - const normalizedField = normalize(String(field)); - return normalizedField.includes(normalizedQuery) || normalizedQuery.includes(normalizedField); - }); - - if (isMatch) { - results.push({ - 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 || '', - availableQuantity: data.availableQuantity ?? null, - totalQuantity: data.totalQuantity ?? null, - }); + // PASS 1 : recherche stricte / exacte (id, nom complet, modele) + for (const item of docs) { + const candText = normalize([item.id, item.name, item.brand, item.model].join(' ')); + if ( + candText === normalizedQuery + || candText.includes(normalizedQuery) + || normalizedQuery.includes(candText) + ) { + results.push(item); } - }); + } + + // PASS 2 : recherche par tokens (mots-clés, marque, modèle partiel) + if (results.length === 0) { + for (const item of docs) { + const candText = normalize([item.id, item.name, item.brand, item.model].join(' ')); + const tokenMatch = tokens.some((t) => candText.includes(t)); + if (tokenMatch) results.push(item); + } + } + + // PASS 3 : fallback catégoriel si aucune correspondance directe + if (results.length === 0) { + // Essayer d'identifier une marque ou categorie depuis les tokens + const possibleBrand = tokens.length > 0 ? tokens[0] : null; + // Rechercher d'abord par brand + if (possibleBrand) { + for (const item of docs) { + if (item.brand && normalize(String(item.brand)).includes(possibleBrand)) { + results.push(item); + } + } + } + + // Si toujours rien, appeler list_equipment_by_category pour élargir la recherche + if (results.length === 0) { + // tenter de deviner une catégorie depuis la query (prend premier token long) + const guessedCategory = tokens.find((t) => t.length >= 3) || null; + if (guessedCategory) { + try { + const catRes = await toolListEquipmentByCategory(guessedCategory, null); + if (catRes && Array.isArray(catRes.results) && catRes.results.length > 0) { + // essayer un fuzzy match dans la categorie + const normQuery = normalizedQuery; + for (const itm of catRes.results) { + const cand = normalize([itm.id, itm.name, itm.brand, itm.model].join(' ')); + if (cand.includes(normQuery) || tokens.some((t) => cand.includes(t))) { + results.push(itm); + } + } + } + } catch (e) { + // ignore fallback errors + } + } + } + } const limited = results.slice(0, SEARCH_RESULTS_LIMIT); @@ -411,6 +491,125 @@ function buildAvailabilityResultForEquipment({ }; } +function buildAvailabilityResultForContainer({ + containerDoc, + containerId, + start, + end, + candidateDocs, + excludeEventId, +}) { + const conflicts = []; + + for (const eventDoc of candidateDocs) { + if (excludeEventId && eventDoc.id === excludeEventId) continue; + + const event = eventDoc.data(); + if (isCancelledStatus(event.status)) continue; + + const eventStart = toDateSafe(event.StartDateTime); + const eventEnd = toDateSafe(event.EndDateTime); + if (!eventStart || !eventEnd) continue; + + // Vérifier si le container est assigné à cet événement + const assignedContainers = Array.isArray(event.assignedContainers) ? event.assignedContainers : []; + const isAssigned = assignedContainers.some((c) => String(c) === String(containerId)); + + // Vérifier chevauchement de dates + const hasOverlap = start < eventEnd && end > eventStart; + if (!hasOverlap) continue; + + if (isAssigned) { + conflicts.push({ + eventId: eventDoc.id, + eventName: event.name || event.Name || 'Événement sans nom', + }); + } + } + + return { + containerId: containerId, + available: conflicts.length === 0, + conflictCount: conflicts.length, + conflicts, + }; +} + +/** + * Vérifie la disponibilité d'un container et son contenu sur une période donnée. + */ +async function toolCheckContainerAvailability(containerId, startDate, endDate, excludeEventId, sharedContext) { + if (!containerId || !startDate || !endDate) { + return { error: 'containerId, startDate et endDate sont requis.' }; + } + + const start = new Date(startDate); + const end = new Date(endDate); + if (isNaN(start.getTime()) || isNaN(end.getTime())) return { error: 'Dates invalides.' }; + + // Charger le container + const containerDocRef = await getDb().collection('containers').doc(containerId).get(); + if (!containerDocRef.exists) { + return { containerId, error: 'Container introuvable.' }; + } + const containerData = containerDocRef.data() || {}; + const equipmentIds = Array.isArray(containerData.equipmentIds) ? containerData.equipmentIds.map((id) => String(id)) : []; + + const candidateDocs = await loadAvailabilityCandidates(start, sharedContext); + + // Vérifier si le container lui-même est assigné + const containerAvailability = buildAvailabilityResultForContainer({ + containerDoc: containerData, + containerId, + start, + end, + candidateDocs, + excludeEventId, + }); + + // Vérifier aussi la disponibilité des équipements internes + let membersAvailability = null; + if (equipmentIds.length > 0) { + const batchRes = await toolCheckAvailabilityBatch(equipmentIds, startDate, endDate, excludeEventId, sharedContext); + membersAvailability = Array.isArray(batchRes.results) ? batchRes.results : []; + } + + // Si un membre est indisponible ou le container est assigné -> indisponible + const anyMemberConflict = Array.isArray(membersAvailability) && membersAvailability.some((m) => m && m.available === false); + const finalAvailable = containerAvailability.available && !anyMemberConflict; + + return { + containerId, + available: finalAvailable, + containerConflicts: containerAvailability.conflicts || [], + memberAvailability: membersAvailability || [], + }; +} + +/** + * Vérifie en batch la disponibilité de plusieurs containers. + */ +async function toolCheckContainerAvailabilityBatch(containerIds, startDate, endDate, excludeEventId, sharedContext) { + if (!Array.isArray(containerIds) || containerIds.length === 0 || !startDate || !endDate) { + return { error: 'containerIds (array), startDate et endDate sont requis.' }; + } + + const normalized = Array.from(new Set(containerIds.map((c) => String(c || '').trim()).filter(Boolean))).slice(0, MAX_BATCH_AVAILABILITY_ITEMS); + if (normalized.length === 0) return { error: 'Aucun containerId valide.' }; + + const results = []; + for (const cid of normalized) { + try { + const r = await toolCheckContainerAvailability(cid, startDate, endDate, excludeEventId, sharedContext); + results.push(r); + } catch (e) { + results.push({ containerId: cid, error: e?.message || 'Erreur interne' }); + } + } + + return { startDate, endDate, count: results.length, results }; +} + /** * Vérifie la disponibilité d'une liste d'équipements pour la même période. */ @@ -530,6 +729,20 @@ function normalizeSearchText(value) { .trim(); } +/** + * Retourne true si le message utilisateur autorise l'utilisation de containers. + * Détecte quelques formulations négatives courantes en français/anglais. + */ +function userAllowsContainers(userMessage) { + if (!userMessage || userMessage.toString().trim().length === 0) return true; + const txt = userMessage.toString().toLowerCase(); + const negativePatterns = [ + 'pas de container', 'sans container', 'pas de flight', 'sans flight', 'pas de flight case', 'sans flight case', + 'ne pas utiliser', 'ne pas prendre', 'do not use', 'no container', 'no flight', 'no flight case' + ]; + return !negativePatterns.some((pat) => txt.includes(pat)); +} + function parseDateHintFlexible(value) { const directDate = toDateSafe(value); if (directDate) { @@ -949,6 +1162,24 @@ async function executeTool(toolCall, excludeEventId, sharedContext) { case 'search_containers': return await toolSearchContainers(args.equipmentIds, args.query || null); + case 'check_container_availability': + return await toolCheckContainerAvailability( + args.containerId, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, + ); + + case 'check_container_availability_batch': + return await toolCheckContainerAvailabilityBatch( + args.containerIds, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, + ); + case 'list_equipment_by_category': return await toolListEquipmentByCategory(args.category || null, args.subCategory || null); @@ -1132,10 +1363,13 @@ function parseAiResponse(rawText, logDebug, logError) { } /** - * Envoie un message à Gemini avec une temporisation en cas de Rate Limit (429). + * Envoie un message à Gemini avec une stratégie de retry robuste. + * Exponential backoff : délais augmentent avec chaque tentative. + * 503 (surcharge) = plus d'attente que 429 (quota). */ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { - const maxRetries = 5; + const maxRetries = 8; + const baseDelayMs = 5000; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -1147,15 +1381,26 @@ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand'); if ((isRateLimit || isOverloaded) && attempt < maxRetries) { - let delayMs = isOverloaded ? 5000 : 30000; // Plus court pour 503, long pour 429 + // Exponential backoff : 5s * (2 ^ (attempt - 1)) + // Surcharge (503) : attente plus longue car le modèle est saturé + // Quota (429) : attente plus courte car c'est juste une limite de débit + const exponentialMultiplier = Math.pow(2, Math.max(0, attempt - 1)); + let delayMs = isOverloaded + ? baseDelayMs * exponentialMultiplier * 3 // 15s, 30s, 60s, 120s, etc. + : baseDelayMs * exponentialMultiplier; // 5s, 10s, 20s, 40s, etc. + // Si le serveur indique un délai spécifique, le respecter const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i); if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) { delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000; } - const reason = isOverloaded ? 'Modèle surchargé (503)' : 'Quota atteint (429)'; - logDebug(`[AI] ${reason}. Temporisation de ${delayMs / 1000}s avant la tentative ${attempt + 1}/${maxRetries}...`); + const reason = isOverloaded ? 'Modèle surchargé (503 - haute demande)' : 'Quota atteint (429)'; + logDebug( + `[AI] ${reason}. Exponential backoff : tentative ${attempt}/${maxRetries}, ` + + `attente de ${Math.round(delayMs / 1000)}s avant nouvelle tentative...`, + { status, attempt, maxRetries } + ); await new Promise((resolve) => setTimeout(resolve, delayMs)); } else { throw error; @@ -1180,6 +1425,28 @@ async function handleAiEquipmentProposal(req, res) { logger.error(msg, data); }; + // Récupérer la valeur de firebase functions:config si disponible + let firebaseConfigKey = null; + try { + const config = functions.config(); + firebaseConfigKey = config.gemini?.api_key || config.gemini?.apikey; + } catch (e) { + // Ignoré + } + + // Évaluer la clé dynamiquement au moment de l'appel + const dynamicApiKey = process.env.GEMINI_API_KEY || firebaseConfigKey || process.env.GOOGLE_AI_API_KEY; + + if (!dynamicApiKey) { + logError('[AI] Configuration manquante : GEMINI_API_KEY / GOOGLE_AI_API_KEY absent'); + res.status(500).json({ + assistantMessage: 'La configuration de l\'IA est incomplète côté serveur. Veuillez configurer la clé Gemini avant de réessayer.', + proposal: null, + debugLogs, + }); + return; + } + const { eventName, eventTypeId, @@ -1201,7 +1468,7 @@ async function handleAiEquipmentProposal(req, res) { return; } - const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const genAI = new GoogleGenerativeAI(dynamicApiKey); const model = genAI.getGenerativeModel({ model: GEMINI_MODEL, systemInstruction: SYSTEM_PROMPT, @@ -1464,6 +1731,87 @@ async function handleAiEquipmentProposal(req, res) { } } + // Post-traitement : privilégier les containers si ceux-ci contiennent uniquement des équipements + // demandés par l'événement (i.e. tous les équipements du container sont nécessaires). + try { + if (proposal && Array.isArray(proposal.items) && proposal.items.length > 0) { + const allowContainers = userAllowsContainers(userMessage); + logDebug('[AI] Vérification auto des containers possible (autorisé par l utilisateur) :', { allowContainers }); + + if (allowContainers) { + const itemIds = proposal.items.map((it) => String(it.equipmentId)); + if (itemIds.length > 0) { + const searchArgs = { equipmentIds: itemIds }; + const cacheKey = buildToolCacheKey('search_containers', searchArgs); + let containersResult = toolResultCache.get(cacheKey); + if (!containersResult) { + containersResult = await executeTool({ name: 'search_containers', args: searchArgs }, excludeEventId, sharedToolContext); + toolResultCache.set(cacheKey, containersResult); + } + + if (containersResult && Array.isArray(containersResult.containers) && containersResult.containers.length > 0) { + const itemsSet = new Set(itemIds); + proposal.containers = proposal.containers || []; + + for (const c of containersResult.containers) { + const cEquip = Array.isArray(c.equipmentIds) ? c.equipmentIds.map((id) => String(id)) : []; + if (cEquip.length === 0) continue; + + const matching = Array.isArray(c.matchingEquipmentIds) ? c.matchingEquipmentIds.map((id) => String(id)) : cEquip.filter((id) => itemsSet.has(String(id))); + const missing = cEquip.filter((id) => !itemsSet.has(String(id))); + const isFull = matching.length > 0 && missing.length === 0; + + // Vérifier la disponibilité du container (et de ses membres) pour la période + const checkArgs = { containerId: c.id, startDate: startDate, endDate: endDate }; + const checkKey = buildToolCacheKey('check_container_availability', checkArgs); + let containerAvail = toolResultCache.get(checkKey); + if (!containerAvail) { + try { + containerAvail = await executeTool({ name: 'check_container_availability', args: checkArgs }, excludeEventId, sharedToolContext); + } catch (err) { + containerAvail = { containerId: c.id, available: true, error: err?.message || 'Erreur check' }; + } + toolResultCache.set(checkKey, containerAvail); + } + + const containerEntry = { + containerId: String(c.id), + rationale: `Conteneur ${c.name || c.id} (matching: ${matching.length}/${cEquip.length})`, + equipmentIds: cEquip, + matchingEquipmentIds: matching, + missingEquipmentIds: missing, + matchingCount: matching.length, + totalItemCount: cEquip.length, + coverageRatio: c.coverageRatio || (matching.length / Math.max(1, itemsSet.size)), + partial: !isFull, + available: containerAvail && typeof containerAvail.available === 'boolean' ? containerAvail.available : true, + availabilityDetail: containerAvail || null, + }; + + // Si le container couvre totalement ses éléments -> favoriser et retirer les items individuels + if (isFull) { + const already = proposal.containers.find((pc) => pc.containerId === c.id); + if (!already) { + proposal.containers.push(containerEntry); + logDebug('[AI] Ajout automatique du container car tous ses éléments sont demandés :', { containerId: c.id }); + } + + const covered = new Set(cEquip.map((id) => String(id))); + proposal.items = proposal.items.filter((it) => !covered.has(String(it.equipmentId))); + } else if (matching.length > 0) { + // Container partiel : proposer à l'utilisateur d'ajouter le container complète + proposal.containers.push(containerEntry); + logDebug('[AI] Container partiel proposé (non retiré des items) :', { containerId: c.id, matchingCount: matching.length, missingCount: missing.length }); + } + } + } + } + } + } + } catch (e) { + logError('[AI] Erreur pendant l auto-selection des containers :', { message: e?.message || e }); + } + res.status(200).json({ assistantMessage, proposal, debugLogs }); } diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index f4bb1fa..eb77bda 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -3,8 +3,11 @@ * Architecture backend sécurisée avec authentification et permissions */ -// Charger les variables d'environnement depuis .env -require('dotenv').config(); +// Charger les variables d'environnement depuis .env.local (développement) +// ou .env (production Firebase) +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env.local') }); +require('dotenv').config({ path: path.join(__dirname, '.env') }); const { onRequest, onCall } = require("firebase-functions/v2/https"); const { onSchedule } = require("firebase-functions/v2/scheduler"); diff --git a/em2rp/functions/package-lock.json b/em2rp/functions/package-lock.json index 2f39817..fdbcf48 100644 --- a/em2rp/functions/package-lock.json +++ b/em2rp/functions/package-lock.json @@ -13,7 +13,7 @@ "dotenv": "^17.2.3", "envdot": "^0.0.3", "firebase-admin": "^12.6.0", - "firebase-functions": "^7.0.3", + "firebase-functions": "^7.2.5", "handlebars": "^4.7.8", "nodemailer": "^6.10.1" }, @@ -3364,9 +3364,9 @@ } }, "node_modules/firebase-functions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz", - "integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==", + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz", + "integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==", "license": "MIT", "peer": true, "dependencies": { @@ -3385,7 +3385,8 @@ "peerDependencies": { "@apollo/server": "^5.2.0", "@as-integrations/express4": "^1.1.2", - "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", + "graphql": "^16.12.0" }, "peerDependenciesMeta": { "@apollo/server": { @@ -3393,6 +3394,9 @@ }, "@as-integrations/express4": { "optional": true + }, + "graphql": { + "optional": true } } }, diff --git a/em2rp/functions/package.json b/em2rp/functions/package.json index b0740b0..f4bbf4e 100644 --- a/em2rp/functions/package.json +++ b/em2rp/functions/package.json @@ -21,7 +21,7 @@ "dotenv": "^17.2.3", "envdot": "^0.0.3", "firebase-admin": "^12.6.0", - "firebase-functions": "^7.0.3", + "firebase-functions": "^7.2.5", "handlebars": "^4.7.8", "nodemailer": "^6.10.1" }, diff --git a/em2rp/lib/services/ai_equipment_assistant_service.dart b/em2rp/lib/services/ai_equipment_assistant_service.dart index 345ccbe..0dc3f00 100644 --- a/em2rp/lib/services/ai_equipment_assistant_service.dart +++ b/em2rp/lib/services/ai_equipment_assistant_service.dart @@ -38,6 +38,29 @@ class AiEquipmentProposalItem { }); } +/// 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; @@ -46,14 +69,16 @@ class AiEquipmentProposal { /// Équipements individuels prêts à être injectés dans l'état local de l'événement. final List asEventEquipment; - /// IDs des containers (flight cases) proposés par l'IA. - final List containerIds; + /// 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.containerIds, + required this.containers, }); } @@ -156,7 +181,7 @@ class AiEquipmentAssistantService { final proposalItems = []; final eventEquipmentList = []; - final containerIds = []; + // legacy containerIds variable removed (we now use containersMeta) final rawItems = rawProposal['items']; if (rawItems is List) { @@ -184,19 +209,64 @@ class AiEquipmentAssistantService { } } + 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.isNotEmpty) { - containerIds.add(containerId); + 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 && containerIds.isEmpty) return null; + if (proposalItems.isEmpty && containersMeta.isEmpty) return null; return AiEquipmentProposal( summary: rawProposal['summary']?.toString().trim().isNotEmpty == true @@ -204,7 +274,7 @@ class AiEquipmentAssistantService { : 'Proposition matériel générée automatiquement.', items: proposalItems, asEventEquipment: eventEquipmentList, - containerIds: containerIds, + containers: containersMeta, ); } } diff --git a/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart index a5d6f21..6cd77cb 100644 --- a/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart +++ b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart @@ -52,6 +52,7 @@ class _AiEquipmentAssistantDialogState late List _workingEquipment; AiEquipmentDocument? _selectedDocument; List _sessionLogs = []; + Set _selectedContainerIds = {}; @override void initState() { @@ -134,6 +135,11 @@ class _AiEquipmentAssistantDialogState _workingEquipment = List.from( response.proposal!.asEventEquipment, ); + // Préselectionner les containers non partiels + _selectedContainerIds = { + for (final c in response.proposal!.containers) + if (!c.partial) c.containerId + }; } _sessionLogs.addAll(response.debugLogs); _isLoading = false; @@ -267,7 +273,7 @@ class _AiEquipmentAssistantDialogState children: [ AppBar( automaticallyImplyLeading: false, - title: const Text('Assistant IA Logisticien'), + title: const Text('(BETA) Assistant IA Logisticien'), actions: [ if (_sessionLogs.isNotEmpty) IconButton( @@ -422,13 +428,13 @@ class _AiEquipmentAssistantDialogState bottomLeft: Radius.circular(message.isUser ? 16 : 4), bottomRight: Radius.circular(message.isUser ? 4 : 16), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], border: message.isUser ? null : Border.all(color: Colors.grey.shade200), ), @@ -488,11 +494,11 @@ class _AiEquipmentAssistantDialogState width: double.infinity, margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.3)), - ), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -500,11 +506,13 @@ class _AiEquipmentAssistantDialogState children: [ Icon(icon, size: 18, color: color), const SizedBox(width: 8), - Text( - title.replaceAll(':', '').trim(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: color, + Expanded( + child: Text( + title.replaceAll(':', '').trim(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + ), ), ), ], @@ -525,7 +533,10 @@ class _AiEquipmentAssistantDialogState if (_latestProposal == null) return; List equipment = List.from(_latestProposal!.asEventEquipment); - List containerIds = List.from(_latestProposal!.containerIds); + // Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets) + final List containerIds = _selectedContainerIds.isNotEmpty + ? _selectedContainerIds.toList() + : List.from(_latestProposal!.containerIds); if (excludeAlternatives) { // On utilise la liste des items d'origine pour savoir lesquels exclure @@ -558,9 +569,9 @@ class _AiEquipmentAssistantDialogState padding: const EdgeInsets.all(16), constraints: const BoxConstraints(maxHeight: 280), decoration: BoxDecoration( - color: Colors.indigo.shade50, + color: Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.indigo.shade200), + border: Border.all(color: Colors.grey.shade300), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), @@ -576,12 +587,14 @@ class _AiEquipmentAssistantDialogState children: [ const Icon(Icons.assignment_turned_in, color: Colors.indigo), const SizedBox(width: 12), - const Text( - 'Récapitulatif de la proposition IA', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Colors.indigo, + const Expanded( + child: Text( + 'Récapitulatif de la proposition IA', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.indigo, + ), ), ), ], @@ -633,21 +646,58 @@ class _AiEquipmentAssistantDialogState ); }), ], - if (proposal.containerIds.isNotEmpty) ...[ + if (proposal.containers.isNotEmpty) ...[ const SizedBox(height: 12), const Text( 'Fly-cases & Boîtes :', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), ), const SizedBox(height: 4), - ...proposal.containerIds.map((id) { + ...proposal.containers.map((c) { + final isPartial = c.partial; + final isSelected = _selectedContainerIds.contains(c.containerId); return Padding( padding: const EdgeInsets.only(bottom: 6, left: 4), child: Row( children: [ - const Icon(Icons.inventory_2_outlined, size: 14, color: Colors.indigo), + Icon( + Icons.inventory_2_outlined, + size: 14, + color: c.available == false ? Colors.red : Colors.indigo, + ), const SizedBox(width: 8), - Text(id, style: const TextStyle(fontWeight: FontWeight.w500)), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))), + if (c.available == false) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon(Icons.block, color: Colors.red.shade700, size: 14), + ), + ], + ), + if (isPartial) ...[ + const SizedBox(height: 4), + Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)), + ], + ], + ), + ), + const SizedBox(width: 8), + if (isPartial) + Checkbox( + value: isSelected, + onChanged: (v) { + setState(() { + if (v == true) _selectedContainerIds.add(c.containerId); + else _selectedContainerIds.remove(c.containerId); + }); + }, + ), ], ), );