/** * 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 };