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:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user