89ab3673c4
**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.
1304 lines
41 KiB
JavaScript
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 };
|
|
|