- 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.
1226 lines
38 KiB
JavaScript
1226 lines
38 KiB
JavaScript
/**
|
||
* 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 };
|
||
|