feat: amélioration de l'assistant IA logisticien et de la gestion des containers

- **Backend (Cloud Functions)** :
    - Mise à jour de `firebase-functions` vers la version `7.2.5`.
    - Amélioration de la sécurité et de la flexibilité des clés API Gemini (support des variables d'environnement `.env` et `.env.local`).
    - Optimisation de la recherche d'équipements avec une stratégie multi-passes (exacte, par tokens, puis catégorielle/fuzzy).
    - Ajout de nouveaux outils pour l'IA : `check_container_availability` et `check_container_availability_batch` pour vérifier la disponibilité des flight-cases.
    - Implémentation d'un post-traitement automatique suggérant des containers complets si tous leurs équipements internes sont requis par l'événement.
    - Amélioration de la résilience aux erreurs 429/503 de Gemini avec une stratégie d'exponential backoff.

- **Frontend (Flutter)** :
    - Mise à jour du service `AiEquipmentAssistantService` pour gérer les métadonnées détaillées des containers (rationale, items manquants/matchings, disponibilité).
    - Refonte de l'interface `AiEquipmentAssistantDialog` :
        - Affichage enrichi des containers dans le récapitulatif.
        - Ajout de la possibilité de sélectionner/désélectionner manuellement les containers (notamment ceux marqués comme "partiels").
        - Amélioration visuelle (ombres, bordures, icônes de statut de disponibilité).
        - Marquage de l'assistant en mode "BETA".

- **Général** :
    - Mise à jour du `.gitignore` pour inclure `functions/.env.local`.
    - Correction de typos et amélioration du logging de debug dans le backend.
This commit is contained in:
ElPoyo
2026-05-25 23:00:43 +02:00
parent 7fc28f4374
commit 7258509528
7 changed files with 563 additions and 87 deletions
+388 -40
View File
@@ -9,8 +9,16 @@ 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 versio, celle ci existe
const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc';
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
@@ -140,6 +148,32 @@ const AI_TOOLS = [
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.',
@@ -226,50 +260,96 @@ async function toolSearchEquipment(query) {
return { query, count: 0, results: [] };
}
// Normalisation agressive pour la recherche (sans accents, sans ponctuation)
// Normalisation pour comparer proprement
const normalize = (str) =>
(str || '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/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 = [];
snapshot.docs.forEach((doc) => {
const data = doc.data();
const searchableFields = [
data.name,
doc.id,
data.model,
data.brand,
data.category,
data.subCategory,
];
const isMatch = searchableFields.some(field => {
if (!field) return false;
const normalizedField = normalize(String(field));
return normalizedField.includes(normalizedQuery) || normalizedQuery.includes(normalizedField);
});
if (isMatch) {
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,
});
// 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);
@@ -411,6 +491,125 @@ function buildAvailabilityResultForEquipment({
};
}
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.
*/
@@ -530,6 +729,20 @@ function normalizeSearchText(value) {
.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) {
@@ -949,6 +1162,24 @@ async function executeTool(toolCall, excludeEventId, sharedContext) {
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);
@@ -1132,10 +1363,13 @@ function parseAiResponse(rawText, logDebug, logError) {
}
/**
* Envoie un message à Gemini avec une temporisation en cas de Rate Limit (429).
* 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 = 5;
const maxRetries = 8;
const baseDelayMs = 5000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
@@ -1147,15 +1381,26 @@ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) {
const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand');
if ((isRateLimit || isOverloaded) && attempt < maxRetries) {
let delayMs = isOverloaded ? 5000 : 30000; // Plus court pour 503, long pour 429
// 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)' : 'Quota atteint (429)';
logDebug(`[AI] ${reason}. Temporisation de ${delayMs / 1000}s avant la tentative ${attempt + 1}/${maxRetries}...`);
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;
@@ -1180,6 +1425,28 @@ async function handleAiEquipmentProposal(req, res) {
logger.error(msg, data);
};
// Récupérer la valeur de firebase functions:config si disponible
let firebaseConfigKey = null;
try {
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,
@@ -1201,7 +1468,7 @@ async function handleAiEquipmentProposal(req, res) {
return;
}
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const genAI = new GoogleGenerativeAI(dynamicApiKey);
const model = genAI.getGenerativeModel({
model: GEMINI_MODEL,
systemInstruction: SYSTEM_PROMPT,
@@ -1464,6 +1731,87 @@ async function handleAiEquipmentProposal(req, res) {
}
}
// 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 });
}