Files
EM2_ERP/em2rp/functions/aiEquipmentProposal.js
T
ElPoyo 89ab3673c4 ### Key Changes:
**AI Equipment Proposal (`functions/aiEquipmentProposal.js`):**
- Updated Gemini model to `gemini-3.1-flash-lite-preview` and updated the API key.
- Increased `MAX_TOOL_ITERATIONS` from 12 to 20.
- Added a new tool `list_equipment_by_category` to allow the AI to browse equipment when specific searches fail.
- Enhanced the system prompt with instructions to handle typos via category exploration and authorized more creative equipment suggestions based on event descriptions.
- Improved the user prompt to include more event context (name, location, notes, and options).
- Set `responseMimeType: 'application/json'` in the generation config for better reliability.
- Improved error logging and user-facing error messages for timeouts.

**UI & Pagination (`lib/views/`):**
- **ContainerFormPage**: Replaced `StreamBuilder` with a paginated list using `DataService` for equipment selection. Added a scroll controller to support infinite scrolling and updated UI colors to use the newer `withValues` API.
- **EquipmentSelectionDialog**:
    - Increased pagination limit from 25 to 50 items.
    - Implemented `_checkIfMoreItemsNeeded` logic to automatically fetch more pages if filters (like hiding conflicting items) leave the view too empty.
    - Added a `NotificationListener` to the `ListView` to trigger pagination on scroll.
    - Fixed minor encoding issues in comments.

---

### Proposed Commit Message:

feat: Mise à jour du modèle Gemini et optimisation de la sélection du matériel avec pagination

- Mise à jour du modèle d'IA vers `gemini-3.1-flash-lite-preview` et augmentation de la limite d'itérations des outils à 20.
- Ajout de l'outil `list_equipment_by_category` pour permettre à l'IA d'explorer les alternatives en cas d'échec de recherche textuelle.
- Enrichissement du prompt système et du contexte envoyé à l'IA (nom, lieu, notes et options de l'événement).
- Implémentation de la pagination dans `ContainerFormPage` pour la sélection d'équipements afin d'améliorer les performances.
- Optimisation de `EquipmentSelectionDialog` avec chargement automatique des pages suivantes si les filtres réduisent trop la liste visible.
- Passage à `withValues` pour la gestion des couleurs et amélioration de la gestion des erreurs et du logging.
2026-03-30 12:32:33 +02:00

1304 lines
41 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-3.1-flash-lite-preview';
const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc';
const MAX_TOOL_ITERATIONS = 20;
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'],
},
},
{
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 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 une recherche precise echoue, utilise list_equipment_by_category pour explorer les categories ou trouver corriger d'eventuelles fautes de frappe de l'utilisateur.
- 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.
- Sois libre d'ajouter du materiel pertinent par rapport aux options ou a la description de l'evenement si cela te semble justifie. Explique tes choix dans rationale.
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,
};
}
/**
* Recherche des équipements par catégorie et sous-catégorie.
*/
async function toolListEquipmentByCategory(category, subCategory) {
let queryDb = getDb().collection('equipments');
if (category) {
queryDb = queryDb.where('category', '==', category);
}
if (subCategory) {
queryDb = queryDb.where('subCategory', '==', subCategory);
}
const snapshot = await queryDb.limit(50).get();
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 || '',
};
});
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 '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'}`,
`- 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 {
eventName,
eventTypeId,
startDate,
endDate,
location,
notes,
eventOptions,
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, responseMimeType: 'application/json' },
});
// 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,
eventName,
eventTypeId,
startDate,
endDate,
location,
notes,
eventOptions,
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',
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,
});
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 };