Files
EM2_ERP/em2rp/functions/aiEquipmentProposal.js
T

1820 lines
64 KiB
JavaScript

/**
* Cloud Function : Assistant IA Logisticien
* Utilise Gemini avec function calling cote serveur.
* Les tools accedent directement a Firestore via Admin SDK.
* L'authentification Firebase est requise (pas de cle API cote client).
*/
const {GoogleGenerativeAI} = require("@google/generative-ai");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const GEMINI_MODEL = "gemini-3.1-flash-lite"; // Ne pas changer de version, celle ci existe
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY || "";
// Log de démarrage pour debug
if (GEMINI_API_KEY) {
const maskedKey = GEMINI_API_KEY.substring(0, 8) + "..." + GEMINI_API_KEY.substring(GEMINI_API_KEY.length - 5);
logger.info(`[AI] GEMINI_API_KEY chargée (masquée: ${maskedKey})`);
} else {
logger.warn("[AI] ⚠️ GEMINI_API_KEY non trouvée ! Vérifiez .env.local ou les variables d'environnement Firebase");
}
const MAX_TOOL_ITERATIONS = 20;
const PAST_EVENTS_LIMIT = 5;
const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle
const EVENT_SEARCH_SCAN_LIMIT = 100;
const MAX_TOOL_CALLS_PER_ITERATION = 40;
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"],
},
},
{
name: "check_container_availability",
description: "Vérifie si un container (flight case) et son contenu sont disponibles pour une période donnée.",
parameters: {
type: "object",
properties: {
containerId: {type: "string", description: "ID exact du container à vérifier."},
startDate: {type: "string", description: "Date de début ISO-8601."},
endDate: {type: "string", description: "Date de fin ISO-8601."},
},
required: ["containerId", "startDate", "endDate"],
},
},
{
name: "check_container_availability_batch",
description: "Vérifie la disponibilité d une liste de containers pour la même période en un seul appel.",
parameters: {
type: "object",
properties: {
containerIds: {type: "array", items: {type: "string"}, description: "Liste des IDs de containers (max 50)."},
startDate: {type: "string", description: "Date de début ISO-8601."},
endDate: {type: "string", description: "Date de fin ISO-8601."},
},
required: ["containerIds", "startDate", "endDate"],
},
},
{
name: "list_equipment_by_category",
description: "Liste le materiel d'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d'une faute de frappe ou pour explorer les alternatives.",
parameters: {
type: "object",
properties: {
category: {
type: "string",
description: "Nom de la categorie.",
nullable: true,
},
subCategory: {
type: "string",
description: "Nom de la sous-categorie.",
nullable: true,
},
},
},
},
],
},
];
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 sur les UNITES et QUANTITES (CRITIQUE) :
- La plupart des equipements (Lumiere, Son, Video, Structure) sont des UNITES UNIQUES. Chaque ID (ex: "BARRE_LED_#1") represente un seul appareil physique.
- Si l'utilisateur demande 6 barres LED, tu dois trouver et ajouter 6 IDs DIFFERENTS (ex: "BARRE_LED_#1", "BARRE_LED_#2", etc.) dans proposal.items, chacun avec "quantity": 1.
- Ne jamais mettre "quantity": 6 pour un item dont totalQuantity est 1 ou null.
- Les seules exceptions sont les categories "CONSUMABLE" (Consommable) et "CABLE" (Cable) qui peuvent avoir une quantite > 1 pour un seul ID.
Regles sur les flight cases (PRIORITAIRE) :
- Les flight cases (containers) sont le moyen privilege de transporter le materiel.
- Strategie :
1. Identifie d'abord tous les equipements individuels necessaires.
2. Appelle search_containers avec la liste de TOUS ces IDs d'equipements.
3. Si un ou plusieurs flight cases couvrent une partie ou la totalite du materiel :
- Ajoute les flight cases dans "proposal.containers".
- RETIRE les equipements contenus dans ces flight cases de ta liste "proposal.items".
- Les equipements restants (non contenus dans des flight cases) doivent rester dans "proposal.items".
- Un flight case est toujours preferable a des items individuels.
Regles de recherche (STRATEGIE EN DEUX PASSES) :
1. PREMIER PASSE (RECHERCHE PRECISE) :
- Cherche chaque item demande avec son nom complet (ex: "dB Technologies Ignenia IG5TR").
- Si ca ne donne rien, REESSAYE immediatement avec des mots-clefs plus courts (ex: "Ignenia", "IG5TR", "VIO S118").
- Sois perseverant : les noms en base sont souvent au format "MARQUE_MODELE_#1".
2. DEUXIEME PASSE (ANALYSE CONTEXTUELLE ET CATEGORIELLE) :
- Si la recherche precise echoue, analyse le contexte de l'item dans le devis ou la demande.
- Identifie la categorie probable (ex: "dB Technologies" -> "SONO" ou "SOUND", "Robert Juliat" -> "LUMIERE" ou "LIGHTING").
- Utilise list_equipment_by_category pour recuperer tout le materiel de cette categorie.
- Parcoure la liste recuperee pour trouver un item qui "match" logiquement (ex: l'utilisateur demande "Opera 15" mais l'identifiant est "DBTE_OPERA_15_#1").
- Si tu trouves une correspondance logique dans la liste de la categorie, utilise l'ID exact trouve en base.
Regles de transparence (OBLIGATOIRE) :
- Ton assistantMessage doit TOUJOURS contenir deux sections claires et distinctes si applicable :
1. "Matériel ajouté :" (Utilise exactement ce titre) suivi de la liste des équipements et flight cases que tu as réussi à trouver et qui sont disponibles. Explique brièvement pourquoi.
2. "Matériel non trouvé ou indisponible :" (Utilise exactement ce titre) suivi de la liste précise des éléments de la demande utilisateur que tu n'as pas pu intégrer. Pour chaque élément, précise s'il est "Absent de la base de données" ou "Indisponible (rupture de stock)".
- N'hésite pas à être pédagogue dans tes explications, mais garde ces titres de section exacts pour permettre un affichage structuré dans l'application.
Regles absolues :
- Tu ne dois JAMAIS ecrire en base de donnees.
- Si l utilisateur cite un evenement precis (nom/date), appelle d abord search_event_reference.
- Rend toujours la LISTE FINALE COMPLETE y compris le materiel deja assigne s'il est toujours pertinent.
- Verifie la disponibilite de CHAQUE equipement ET container propose via check_availability_batch.
- Si un materiel est en rupture de stock, cherche une alternative (autre ID du meme modele ou autre modele similaire). Si aucune alternative n'est trouvee, deplace l'item dans la section "indisponible".
Reponse finale : du JSON valide strict, sans markdown, avec ce format exact :
{"assistantMessage":"...","proposal":{"summary":"...","containers":[{"containerId":"ID-EXACT","rationale":"..."}],"items":[{"equipmentId":"ID-EXACT","quantity":1,"rationale":"..."}]}}
- containers : flight cases proposes.
- items : equipements individuels NON contenus dans les flight cases proposes ci-dessus.
- Si impossible, renvoie proposal a null.`;
// ============================================================================
// Implémentation des tools côté serveur (Firestore Admin SDK)
// ============================================================================
/**
* Recherche des équipements dans Firestore par mot-clé.
*/
async function toolSearchEquipment(query) {
if (!query || query.trim().length < 2) {
return {query, count: 0, results: []};
}
// Normalisation pour comparer proprement
const normalize = (str) =>
(str || "")
.toString()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim();
const normalizedQuery = normalize(query);
const tokens = extractQueryTokens(query);
// Charger un snapshot raisonnable une seule fois
const snapshot = await getDb().collection("equipments").limit(500).get();
const docs = snapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
name: String(data.name || doc.id),
category: data.category || "",
subCategory: data.subCategory || "",
brand: data.brand || null,
model: data.model || null,
status: data.status || "",
availableQuantity: data.availableQuantity ?? null,
totalQuantity: data.totalQuantity ?? null,
};
});
const results = [];
// PASS 1 : recherche stricte / exacte (id, nom complet, modele)
for (const item of docs) {
const candText = normalize([item.id, item.name, item.brand, item.model].join(" "));
if (
candText === normalizedQuery ||
candText.includes(normalizedQuery) ||
normalizedQuery.includes(candText)
) {
results.push(item);
}
}
// PASS 2 : recherche par tokens (mots-clés, marque, modèle partiel)
if (results.length === 0) {
for (const item of docs) {
const candText = normalize([item.id, item.name, item.brand, item.model].join(" "));
const tokenMatch = tokens.some((t) => candText.includes(t));
if (tokenMatch) results.push(item);
}
}
// PASS 3 : fallback catégoriel si aucune correspondance directe
if (results.length === 0) {
// Essayer d'identifier une marque ou categorie depuis les tokens
const possibleBrand = tokens.length > 0 ? tokens[0] : null;
// Rechercher d'abord par brand
if (possibleBrand) {
for (const item of docs) {
if (item.brand && normalize(String(item.brand)).includes(possibleBrand)) {
results.push(item);
}
}
}
// Si toujours rien, appeler list_equipment_by_category pour élargir la recherche
if (results.length === 0) {
// tenter de deviner une catégorie depuis la query (prend premier token long)
const guessedCategory = tokens.find((t) => t.length >= 3) || null;
if (guessedCategory) {
try {
const catRes = await toolListEquipmentByCategory(guessedCategory, null);
if (catRes && Array.isArray(catRes.results) && catRes.results.length > 0) {
// essayer un fuzzy match dans la categorie
const normQuery = normalizedQuery;
for (const itm of catRes.results) {
const cand = normalize([itm.id, itm.name, itm.brand, itm.model].join(" "));
if (cand.includes(normQuery) || tokens.some((t) => cand.includes(t))) {
results.push(itm);
}
}
}
} catch (e) {
// ignore fallback errors
}
}
}
}
const limited = results.slice(0, SEARCH_RESULTS_LIMIT);
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;
}
// Verifier si l'equipement est assigne a cet evenement
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,
};
}
function buildAvailabilityResultForContainer({
containerDoc,
containerId,
start,
end,
candidateDocs,
excludeEventId,
}) {
const conflicts = [];
for (const eventDoc of candidateDocs) {
if (excludeEventId && eventDoc.id === excludeEventId) continue;
const event = eventDoc.data();
if (isCancelledStatus(event.status)) continue;
const eventStart = toDateSafe(event.StartDateTime);
const eventEnd = toDateSafe(event.EndDateTime);
if (!eventStart || !eventEnd) continue;
// Vérifier si le container est assigné à cet événement
const assignedContainers = Array.isArray(event.assignedContainers) ? event.assignedContainers : [];
const isAssigned = assignedContainers.some((c) => String(c) === String(containerId));
// Vérifier chevauchement de dates
const hasOverlap = start < eventEnd && end > eventStart;
if (!hasOverlap) continue;
if (isAssigned) {
conflicts.push({
eventId: eventDoc.id,
eventName: event.name || event.Name || "Événement sans nom",
});
}
}
return {
containerId: containerId,
available: conflicts.length === 0,
conflictCount: conflicts.length,
conflicts,
};
}
/**
* Vérifie la disponibilité d'un container et son contenu sur une période donnée.
*/
async function toolCheckContainerAvailability(containerId, startDate, endDate, excludeEventId, sharedContext) {
if (!containerId || !startDate || !endDate) {
return {error: "containerId, startDate et endDate sont requis."};
}
const start = new Date(startDate);
const end = new Date(endDate);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return {error: "Dates invalides."};
// Charger le container
const containerDocRef = await getDb().collection("containers").doc(containerId).get();
if (!containerDocRef.exists) {
return {containerId, error: "Container introuvable."};
}
const containerData = containerDocRef.data() || {};
const equipmentIds = Array.isArray(containerData.equipmentIds) ? containerData.equipmentIds.map((id) => String(id)) : [];
const candidateDocs = await loadAvailabilityCandidates(start, sharedContext);
// Vérifier si le container lui-même est assigné
const containerAvailability = buildAvailabilityResultForContainer({
containerDoc: containerData,
containerId,
start,
end,
candidateDocs,
excludeEventId,
});
// Vérifier aussi la disponibilité des équipements internes
let membersAvailability = null;
if (equipmentIds.length > 0) {
const batchRes = await toolCheckAvailabilityBatch(equipmentIds, startDate, endDate, excludeEventId, sharedContext);
membersAvailability = Array.isArray(batchRes.results) ? batchRes.results : [];
}
// Si un membre est indisponible ou le container est assigné -> indisponible
const anyMemberConflict = Array.isArray(membersAvailability) && membersAvailability.some((m) => m && m.available === false);
const finalAvailable = containerAvailability.available && !anyMemberConflict;
return {
containerId,
available: finalAvailable,
containerConflicts: containerAvailability.conflicts || [],
memberAvailability: membersAvailability || [],
};
}
/**
* Vérifie en batch la disponibilité de plusieurs containers.
*/
async function toolCheckContainerAvailabilityBatch(containerIds, startDate, endDate, excludeEventId, sharedContext) {
if (!Array.isArray(containerIds) || containerIds.length === 0 || !startDate || !endDate) {
return {error: "containerIds (array), startDate et endDate sont requis."};
}
const normalized = Array.from(new Set(containerIds.map((c) => String(c || "").trim()).filter(Boolean))).slice(0, MAX_BATCH_AVAILABILITY_ITEMS);
if (normalized.length === 0) return {error: "Aucun containerId valide."};
const results = [];
for (const cid of normalized) {
try {
const r = await toolCheckContainerAvailability(cid, startDate, endDate, excludeEventId, sharedContext);
results.push(r);
} catch (e) {
results.push({containerId: cid, error: e?.message || "Erreur interne"});
}
}
return {startDate, endDate, count: results.length, results};
}
/**
* Vérifie la disponibilité d'une liste d'équipements pour la même période.
*/
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();
}
/**
* Retourne true si le message utilisateur autorise l'utilisation de containers.
* Détecte quelques formulations négatives courantes en français/anglais.
*/
function userAllowsContainers(userMessage) {
if (!userMessage || userMessage.toString().trim().length === 0) return true;
const txt = userMessage.toString().toLowerCase();
const negativePatterns = [
"pas de container", "sans container", "pas de flight", "sans flight", "pas de flight case", "sans flight case",
"ne pas utiliser", "ne pas prendre", "do not use", "no container", "no flight", "no flight case",
];
return !negativePatterns.some((pat) => txt.includes(pat));
}
function parseDateHintFlexible(value) {
const directDate = toDateSafe(value);
if (directDate) {
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,
};
}
/**
* Recherche des équipements par catégorie et sous-catégorie.
*/
async function toolListEquipmentByCategory(category, subCategory) {
const queryDb = getDb().collection("equipments");
// Si on a des critères précis, on filtre. Sinon on scanne pour matcher flou.
const snapshot = await queryDb.limit(1000).get();
const normalize = (str) =>
(str || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
const normCat = category ? normalize(category) : null;
const normSub = subCategory ? normalize(subCategory) : null;
const results = snapshot.docs
.map((doc) => {
const data = doc.data();
return {
id: doc.id,
name: data.name || doc.id,
category: data.category || "",
subCategory: data.subCategory || "",
brand: data.brand || null,
model: data.model || null,
status: data.status || "",
};
})
.filter((item) => {
let match = true;
if (normCat) match = match && normalize(item.category).includes(normCat);
if (normSub) match = match && normalize(item.subCategory).includes(normSub);
return match;
})
.slice(0, 50);
return {
category: category || "all",
subCategory: subCategory || "all",
count: results.length,
results,
};
}
/**
* Exécute un tool Gemini et retourne le résultat.
*/
async function executeTool(toolCall, excludeEventId, sharedContext) {
const {name, args} = toolCall;
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);
case "check_container_availability":
return await toolCheckContainerAvailability(
args.containerId,
args.startDate,
args.endDate,
excludeEventId,
sharedContext,
);
case "check_container_availability_batch":
return await toolCheckContainerAvailabilityBatch(
args.containerIds,
args.startDate,
args.endDate,
excludeEventId,
sharedContext,
);
case "list_equipment_by_category":
return await toolListEquipmentByCategory(args.category || null, args.subCategory || null);
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,
eventName,
eventTypeId,
startDate,
endDate,
location,
notes,
eventOptions,
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, en tenant compte des options et des détails fournis, et basée sur les événements similaires passés." :
userMessage.trim();
return [
"Contexte de l'événement :",
`- Nom : ${eventName || "non renseigné"}`,
`- Type d'événement (ID): ${eventTypeId || "non renseigné"}`,
`- Date de début : ${startDate}`,
`- Date de fin : ${endDate}`,
`- Lieu : ${location || "non renseigné"}`,
`- Notes/Description : ${notes || "aucune"}`,
`- Options de l'événement : ${eventOptions || "aucune"}`,
`- Materiel deja assigne : ${currentEquipmentStr}`,
`- Proposition courante a modifier : ${workingProposalStr}`,
"",
"Demande :",
finalMessage,
"",
"Si un fichier (document/devis) a été joint à ma demande (sous forme inlineData by Gemini), tu dois absolument l analyser, en deduire le materiel demande, et l ajouter a ta proposition.",
"Si la demande cite un evenement precis (nom/date), commence par search_event_reference avant de proposer du materiel.",
"Si une proposition courante existe, traite-la comme base de travail et renvoie toujours la liste finale complete apres modification.",
"Rappel : vérifier la disponibilité avant de recommander. Privilégier check_availability_batch pour contrôler plusieurs équipements en un appel.",
"En cas d'indisponibilité, chercher une alternative via search_equipment puis revérifier.",
].join("\n");
}
/**
* Construit un tableau de parts pour Gemini permettant d'inclure des documents.
*/
function buildMessageParts(params, logDebug) {
const textPrompt = buildUserPrompt(params);
const parts = [{text: textPrompt}];
if (params.document && params.document.data && params.document.mimeType) {
if (logDebug) logDebug(`[AI] Attaching document ${params.document.mimeType}`);
else logger.info(`[AI] Attaching document ${params.document.mimeType}`);
parts.push({
inlineData: {
data: params.document.data,
mimeType: params.document.mimeType,
},
});
const fileNameInfo = params.document.fileName ? ` (nom du fichier: ${params.document.fileName})` : ``;
parts.push({
text: `J'ai joint un document de type ${params.document.mimeType}${fileNameInfo}. Merci d'extraire le materiel et de l'ajouter a la proposition.`,
});
}
return parts;
}
/**
* Extrait un texte exploitable depuis la réponse Gemini, même si response.text() est vide.
*/
function extractResponseText(modelResponse, logDebug, logError) {
const info = logDebug || logger.info.bind(logger);
const warn = logError || logger.warn.bind(logger);
if (!modelResponse) {
warn("[AI] extractResponseText: modelResponse est vide ou undefined");
return "";
}
info("[AI] extractResponseText: analyse de la réponse brut", {
hasTextFunc: typeof modelResponse.text === "function",
candidatesCount: modelResponse.candidates?.length || 0,
});
try {
const directText = modelResponse.text?.();
if (directText && directText.trim().length > 0) {
info("[AI] extractResponseText: Texte extrait via modelResponse.text()");
return directText.trim();
}
} catch (err) {
warn("[AI] extractResponseText: erreur lors de l appel a modelResponse.text()", err?.message);
}
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());
}
}
}
const fallbackText = textParts.join("\n").trim();
info(`[AI] extractResponseText: Texte extrait en fallback (longueur: ${fallbackText.length})`);
return fallbackText;
}
/**
* Extrait et parse le JSON de la réponse IA.
*/
function parseAiResponse(rawText, logDebug, logError) {
const info = logDebug || logger.info.bind(logger);
const err = logError || logger.error.bind(logger);
if (!rawText || rawText.trim().length === 0) {
err("[AI] parseAiResponse: rawText recu est vide !");
throw new Error("Réponse IA vide.");
}
info("[AI] parseAiResponse tentatives de parsing sur le texte (apercu):", rawText.substring(0, 100) + "...");
// Tentative directe
try {
const parsed = JSON.parse(rawText.trim());
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.");
}
/**
* Envoie un message à Gemini avec une stratégie de retry robuste.
* Exponential backoff : délais augmentent avec chaque tentative.
* 503 (surcharge) = plus d'attente que 429 (quota).
*/
async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) {
const maxRetries = 8;
const baseDelayMs = 5000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await chat.sendMessage(messagePayload);
} catch (error) {
const status = error?.status || error?.response?.status;
const errorMessage = error.message || "";
const isRateLimit = status === 429 || errorMessage.includes("429") || errorMessage.includes("quota");
const isOverloaded = status === 503 || errorMessage.includes("503") || errorMessage.includes("overloaded") || errorMessage.includes("high demand");
if ((isRateLimit || isOverloaded) && attempt < maxRetries) {
// Exponential backoff : 5s * (2 ^ (attempt - 1))
// Surcharge (503) : attente plus longue car le modèle est saturé
// Quota (429) : attente plus courte car c'est juste une limite de débit
const exponentialMultiplier = Math.pow(2, Math.max(0, attempt - 1));
let delayMs = isOverloaded ?
baseDelayMs * exponentialMultiplier * 3 : // 15s, 30s, 60s, 120s, etc.
baseDelayMs * exponentialMultiplier; // 5s, 10s, 20s, 40s, etc.
// Si le serveur indique un délai spécifique, le respecter
const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i);
if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) {
delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000;
}
const reason = isOverloaded ? "Modèle surchargé (503 - haute demande)" : "Quota atteint (429)";
logDebug(
`[AI] ${reason}. Exponential backoff : tentative ${attempt}/${maxRetries}, ` +
`attente de ${Math.round(delayMs / 1000)}s avant nouvelle tentative...`,
{status, attempt, maxRetries},
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
} else {
throw error;
}
}
}
}
/**
* Handler principal de la Cloud Function aiEquipmentProposal.
*/
async function handleAiEquipmentProposal(req, res) {
const debugLogs = [];
const logDebug = (msg, data) => {
const dataStr = data ? ` | ${JSON.stringify(data)}` : "";
debugLogs.push(`[INFO] ${msg}${dataStr}`);
logger.info(msg, data);
};
const logError = (msg, data) => {
const dataStr = data ? ` | ${JSON.stringify(data)}` : "";
debugLogs.push(`[ERROR] ${msg}${dataStr}`);
logger.error(msg, data);
};
// Récupérer la valeur de firebase functions:config si disponible
let firebaseConfigKey = null;
try {
const functions = require("firebase-functions");
const config = functions.config();
firebaseConfigKey = config.gemini?.api_key || config.gemini?.apikey;
} catch (e) {
// Ignoré
}
// Évaluer la clé dynamiquement au moment de l'appel
const dynamicApiKey = process.env.GEMINI_API_KEY || firebaseConfigKey || process.env.GOOGLE_AI_API_KEY;
if (!dynamicApiKey) {
logError("[AI] Configuration manquante : GEMINI_API_KEY / GOOGLE_AI_API_KEY absent");
res.status(500).json({
assistantMessage: "La configuration de l'IA est incomplète côté serveur. Veuillez configurer la clé Gemini avant de réessayer.",
proposal: null,
debugLogs,
});
return;
}
const {
eventName,
eventTypeId,
startDate,
endDate,
location,
notes,
eventOptions,
userMessage,
history = [],
currentEquipment = [],
workingProposal = [],
excludeEventId,
document,
} = req.body.data || {};
if (!startDate || !endDate) {
res.status(400).json({error: "startDate et endDate sont requis."});
return;
}
const genAI = new GoogleGenerativeAI(dynamicApiKey);
const model = genAI.getGenerativeModel({
model: GEMINI_MODEL,
systemInstruction: SYSTEM_PROMPT,
tools: AI_TOOLS,
toolConfig: {functionCallingConfig: {mode: "AUTO"}},
generationConfig: {temperature: 0.2},
});
logDebug("[AI] Reconstruction de l historique de conversation. Elements :", history?.length || 0);
// Reconstruire l'historique de conversation
const chatHistory = (history || [])
.filter((turn) => turn.text && turn.text.trim().length > 0)
.map((turn) => ({
role: turn.isUser ? "user" : "model",
parts: [{text: turn.text.trim()}],
}));
logDebug("[AI] Historique formaté :", chatHistory);
const chat = model.startChat({history: chatHistory});
const toolResultCache = new Map();
const sharedToolContext = {
availabilityCandidatesByWindow: new Map(),
};
const messageParts = buildMessageParts({
userMessage,
eventName,
eventTypeId,
startDate,
endDate,
location,
notes,
eventOptions,
currentEquipment,
workingProposal,
document,
}, logDebug);
// Sécuriser les logs de base64
const safeMessagePartsForLogs = JSON.parse(JSON.stringify(messageParts));
safeMessagePartsForLogs.forEach((part) => {
if (part.inlineData && part.inlineData.data) {
part.inlineData.data = `<BASE64 TRONQUE : ${part.inlineData.data.length} chars>`;
}
});
logDebug("[AI] Starting conversation. Parts envoys:", safeMessagePartsForLogs);
let response;
try {
response = await sendMessageWithRetry(chat, messageParts, logDebug, logError);
logDebug("[AI] Premiere reponse recue du sendMessage.");
// Boucle de function calling avec cache local.
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
const functionCalls = response.response.functionCalls();
if (!functionCalls || functionCalls.length === 0) {
logDebug(`[AI] (Iteration ${iteration + 1}) Fin des tool calls, l IA a retourné une réponse (ou un appel vide).`);
break;
}
logDebug(`[AI] (Iteration ${iteration + 1}) Tool calls demands par l IA:`, functionCalls);
const callsToProcess = functionCalls.slice(0, MAX_TOOL_CALLS_PER_ITERATION);
const availabilityCalls = callsToProcess.filter(
(call) => call.name === "check_availability" &&
call.args?.equipmentId &&
call.args?.startDate &&
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};
logDebug("[AI] Consolidated check_availability calls into one batch call", {
iteration: iteration + 1,
itemCount: equipmentIds.length,
});
}
}
}
logDebug(`[AI] Tool calls executes. Preparation du renvoi des resultats...`);
const toolResults = await Promise.all(
functionCalls.map(async (toolCall, ind) => {
if (ind >= MAX_TOOL_CALLS_PER_ITERATION) {
return {
functionResponse: {
name: toolCall.name,
response: {error: "Limite de tool calls simultanes atteinte. (Ignore)"},
},
};
}
const cacheKey = buildToolCacheKey(toolCall.name, toolCall.args);
if (toolResultCache.has(cacheKey)) {
return {
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) {
logError("[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);
logDebug(`[AI] Resultat du tool ${toolCall.name} (apercu):`, JSON.stringify(toolResult).substring(0, 150) + "...");
return {
functionResponse: {
name: toolCall.name,
response: toolResult,
},
};
}),
);
logDebug(`[AI] Envoi de ${toolResults.length} resultats au modèle pour la suite de la conversation.`);
response = await sendMessageWithRetry(chat, toolResults, logDebug, logError);
logDebug(`[AI] Reponse recue du modele suite aux resultats des tools.`);
}
} catch (error) {
logError("[AI] Conversation timeout/error, DETAIL COMPLET:", {
message: error?.message || "unknown",
status: error?.status,
details: error?.details,
stack: error?.stack,
});
res.status(200).json({
assistantMessage: "Je suis désolé, je n'ai pas réussi à traiter votre demande en raison d'un temps trop long ou d'une erreur technique. Veuillez réessayer avec des requêtes plus spécifiques.",
proposal: null,
debugLogs,
});
return;
}
logDebug("[AI] Structure finale du response.response (apercu):", JSON.stringify(response.response).substring(0, 300) + "...");
const rawText = extractResponseText(response.response, logDebug, logError);
logDebug("[AI] Raw response extracted:", {
hasText: rawText.length > 0,
rawTextExcerpt: rawText.substring(0, 200) + "...",
});
// 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,
debugLogs,
});
return;
}
let payload;
try {
payload = parseAiResponse(rawText, logDebug, logError);
} catch (error) {
logError("[AI] JSON parsing failed, returning degraded response", error?.message);
res.status(200).json({
assistantMessage: rawText,
proposal: null,
debugLogs,
});
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,
};
}
}
// Post-traitement : privilégier les containers si ceux-ci contiennent uniquement des équipements
// demandés par l'événement (i.e. tous les équipements du container sont nécessaires).
try {
if (proposal && Array.isArray(proposal.items) && proposal.items.length > 0) {
const allowContainers = userAllowsContainers(userMessage);
logDebug("[AI] Vérification auto des containers possible (autorisé par l utilisateur) :", {allowContainers});
if (allowContainers) {
const itemIds = proposal.items.map((it) => String(it.equipmentId));
if (itemIds.length > 0) {
const searchArgs = {equipmentIds: itemIds};
const cacheKey = buildToolCacheKey("search_containers", searchArgs);
let containersResult = toolResultCache.get(cacheKey);
if (!containersResult) {
containersResult = await executeTool({name: "search_containers", args: searchArgs}, excludeEventId, sharedToolContext);
toolResultCache.set(cacheKey, containersResult);
}
if (containersResult && Array.isArray(containersResult.containers) && containersResult.containers.length > 0) {
const itemsSet = new Set(itemIds);
proposal.containers = proposal.containers || [];
for (const c of containersResult.containers) {
const cEquip = Array.isArray(c.equipmentIds) ? c.equipmentIds.map((id) => String(id)) : [];
if (cEquip.length === 0) continue;
const matching = Array.isArray(c.matchingEquipmentIds) ? c.matchingEquipmentIds.map((id) => String(id)) : cEquip.filter((id) => itemsSet.has(String(id)));
const missing = cEquip.filter((id) => !itemsSet.has(String(id)));
const isFull = matching.length > 0 && missing.length === 0;
// Vérifier la disponibilité du container (et de ses membres) pour la période
const checkArgs = {containerId: c.id, startDate: startDate, endDate: endDate};
const checkKey = buildToolCacheKey("check_container_availability", checkArgs);
let containerAvail = toolResultCache.get(checkKey);
if (!containerAvail) {
try {
containerAvail = await executeTool({name: "check_container_availability", args: checkArgs}, excludeEventId, sharedToolContext);
} catch (err) {
containerAvail = {containerId: c.id, available: true, error: err?.message || "Erreur check"};
}
toolResultCache.set(checkKey, containerAvail);
}
const containerEntry = {
containerId: String(c.id),
rationale: `Conteneur ${c.name || c.id} (matching: ${matching.length}/${cEquip.length})`,
equipmentIds: cEquip,
matchingEquipmentIds: matching,
missingEquipmentIds: missing,
matchingCount: matching.length,
totalItemCount: cEquip.length,
coverageRatio: c.coverageRatio || (matching.length / Math.max(1, itemsSet.size)),
partial: !isFull,
available: containerAvail && typeof containerAvail.available === "boolean" ? containerAvail.available : true,
availabilityDetail: containerAvail || null,
};
// Si le container couvre totalement ses éléments -> favoriser et retirer les items individuels
if (isFull) {
const already = proposal.containers.find((pc) => pc.containerId === c.id);
if (!already) {
proposal.containers.push(containerEntry);
logDebug("[AI] Ajout automatique du container car tous ses éléments sont demandés :", {containerId: c.id});
}
const covered = new Set(cEquip.map((id) => String(id)));
proposal.items = proposal.items.filter((it) => !covered.has(String(it.equipmentId)));
} else if (matching.length > 0) {
// Container partiel : proposer à l'utilisateur d'ajouter le container complète
proposal.containers.push(containerEntry);
logDebug("[AI] Container partiel proposé (non retiré des items) :", {containerId: c.id, matchingCount: matching.length, missingCount: missing.length});
}
}
}
}
}
}
} catch (e) {
logError("[AI] Erreur pendant l auto-selection des containers :", {message: e?.message || e});
}
res.status(200).json({assistantMessage, proposal, debugLogs});
}
module.exports = {handleAiEquipmentProposal};