Files
EM2_ERP/em2rp/functions/aiEquipmentProposal.js
ElPoyo 84c882ac0b feat: Intégration d'un assistant IA logisticien basé sur Gemini
- 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.
2026-03-24 12:00:30 +01:00

1226 lines
38 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };