From 84c882ac0ba57c4e1f3740a001260afa9cc664d7 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Tue, 24 Mar 2026 12:00:30 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Int=C3=A9gration=20d'un=20assistant=20I?= =?UTF-8?q?A=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