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
+1
View File
@@ -45,3 +45,4 @@ app.*.map.json
# Environment configuration with credentials # Environment configuration with credentials
lib/config/env.dev.dart lib/config/env.dev.dart
functions/.env functions/.env
functions/.env.local
+388 -40
View File
@@ -9,8 +9,16 @@ const { GoogleGenerativeAI } = require('@google/generative-ai');
const admin = require('firebase-admin'); const admin = require('firebase-admin');
const logger = require('firebase-functions/logger'); const logger = require('firebase-functions/logger');
const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de versio, celle ci existe const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de version, celle ci existe
const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc'; 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 MAX_TOOL_ITERATIONS = 20;
const PAST_EVENTS_LIMIT = 5; const PAST_EVENTS_LIMIT = 5;
const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle 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'], 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', 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.', 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: [] }; return { query, count: 0, results: [] };
} }
// Normalisation agressive pour la recherche (sans accents, sans ponctuation) // Normalisation pour comparer proprement
const normalize = (str) => const normalize = (str) =>
(str || '') (str || '')
.toString()
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, ''); .replace(/[^a-z0-9\s-]/g, '')
.trim();
const normalizedQuery = normalize(query); 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 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 = []; const results = [];
snapshot.docs.forEach((doc) => { // PASS 1 : recherche stricte / exacte (id, nom complet, modele)
const data = doc.data(); for (const item of docs) {
const searchableFields = [ const candText = normalize([item.id, item.name, item.brand, item.model].join(' '));
data.name, if (
doc.id, candText === normalizedQuery
data.model, || candText.includes(normalizedQuery)
data.brand, || normalizedQuery.includes(candText)
data.category, ) {
data.subCategory, results.push(item);
];
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 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); 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. * Vérifie la disponibilité d'une liste d'équipements pour la même période.
*/ */
@@ -530,6 +729,20 @@ function normalizeSearchText(value) {
.trim(); .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) { function parseDateHintFlexible(value) {
const directDate = toDateSafe(value); const directDate = toDateSafe(value);
if (directDate) { if (directDate) {
@@ -949,6 +1162,24 @@ async function executeTool(toolCall, excludeEventId, sharedContext) {
case 'search_containers': case 'search_containers':
return await toolSearchContainers(args.equipmentIds, args.query || null); 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': case 'list_equipment_by_category':
return await toolListEquipmentByCategory(args.category || null, args.subCategory || null); 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) { async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) {
const maxRetries = 5; const maxRetries = 8;
const baseDelayMs = 5000;
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { 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'); const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand');
if ((isRateLimit || isOverloaded) && attempt < maxRetries) { 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); const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i);
if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) { if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) {
delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000; delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000;
} }
const reason = isOverloaded ? 'Modèle surchargé (503)' : 'Quota atteint (429)'; const reason = isOverloaded ? 'Modèle surchargé (503 - haute demande)' : 'Quota atteint (429)';
logDebug(`[AI] ${reason}. Temporisation de ${delayMs / 1000}s avant la tentative ${attempt + 1}/${maxRetries}...`); 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)); await new Promise((resolve) => setTimeout(resolve, delayMs));
} else { } else {
throw error; throw error;
@@ -1180,6 +1425,28 @@ async function handleAiEquipmentProposal(req, res) {
logger.error(msg, data); 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 { const {
eventName, eventName,
eventTypeId, eventTypeId,
@@ -1201,7 +1468,7 @@ async function handleAiEquipmentProposal(req, res) {
return; return;
} }
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const genAI = new GoogleGenerativeAI(dynamicApiKey);
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
model: GEMINI_MODEL, model: GEMINI_MODEL,
systemInstruction: SYSTEM_PROMPT, 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 }); res.status(200).json({ assistantMessage, proposal, debugLogs });
} }
+5 -2
View File
@@ -3,8 +3,11 @@
* Architecture backend sécurisée avec authentification et permissions * Architecture backend sécurisée avec authentification et permissions
*/ */
// Charger les variables d'environnement depuis .env // Charger les variables d'environnement depuis .env.local (développement)
require('dotenv').config(); // ou .env (production Firebase)
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
require('dotenv').config({ path: path.join(__dirname, '.env') });
const { onRequest, onCall } = require("firebase-functions/v2/https"); const { onRequest, onCall } = require("firebase-functions/v2/https");
const { onSchedule } = require("firebase-functions/v2/scheduler"); const { onSchedule } = require("firebase-functions/v2/scheduler");
+9 -5
View File
@@ -13,7 +13,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"envdot": "^0.0.3", "envdot": "^0.0.3",
"firebase-admin": "^12.6.0", "firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3", "firebase-functions": "^7.2.5",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"nodemailer": "^6.10.1" "nodemailer": "^6.10.1"
}, },
@@ -3364,9 +3364,9 @@
} }
}, },
"node_modules/firebase-functions": { "node_modules/firebase-functions": {
"version": "7.0.3", "version": "7.2.5",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz", "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==", "integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -3385,7 +3385,8 @@
"peerDependencies": { "peerDependencies": {
"@apollo/server": "^5.2.0", "@apollo/server": "^5.2.0",
"@as-integrations/express4": "^1.1.2", "@as-integrations/express4": "^1.1.2",
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
"graphql": "^16.12.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@apollo/server": { "@apollo/server": {
@@ -3393,6 +3394,9 @@
}, },
"@as-integrations/express4": { "@as-integrations/express4": {
"optional": true "optional": true
},
"graphql": {
"optional": true
} }
} }
}, },
+1 -1
View File
@@ -21,7 +21,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"envdot": "^0.0.3", "envdot": "^0.0.3",
"firebase-admin": "^12.6.0", "firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3", "firebase-functions": "^7.2.5",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"nodemailer": "^6.10.1" "nodemailer": "^6.10.1"
}, },
@@ -38,6 +38,29 @@ class AiEquipmentProposalItem {
}); });
} }
/// Métadonnées pour un container proposé par l'IA.
class AiEquipmentProposalContainer {
final String containerId;
final String rationale;
final List<String> equipmentIds;
final List<String> matchingEquipmentIds;
final List<String> missingEquipmentIds;
final bool partial;
final bool? available;
final dynamic availabilityDetail;
const AiEquipmentProposalContainer({
required this.containerId,
required this.rationale,
this.equipmentIds = const [],
this.matchingEquipmentIds = const [],
this.missingEquipmentIds = const [],
this.partial = false,
this.available,
this.availabilityDetail,
});
}
/// Proposition complète retournée par l'IA. /// Proposition complète retournée par l'IA.
class AiEquipmentProposal { class AiEquipmentProposal {
final String summary; final String summary;
@@ -46,14 +69,16 @@ class AiEquipmentProposal {
/// Équipements individuels prêts à être injectés dans l'état local de l'événement. /// Équipements individuels prêts à être injectés dans l'état local de l'événement.
final List<EventEquipment> asEventEquipment; final List<EventEquipment> asEventEquipment;
/// IDs des containers (flight cases) proposés par l'IA. /// Containers (métadonnées) proposés par l'IA.
final List<String> containerIds; final List<AiEquipmentProposalContainer> containers;
List<String> get containerIds => containers.map((c) => c.containerId).toList();
const AiEquipmentProposal({ const AiEquipmentProposal({
required this.summary, required this.summary,
required this.items, required this.items,
required this.asEventEquipment, required this.asEventEquipment,
required this.containerIds, required this.containers,
}); });
} }
@@ -156,7 +181,7 @@ class AiEquipmentAssistantService {
final proposalItems = <AiEquipmentProposalItem>[]; final proposalItems = <AiEquipmentProposalItem>[];
final eventEquipmentList = <EventEquipment>[]; final eventEquipmentList = <EventEquipment>[];
final containerIds = <String>[]; // legacy containerIds variable removed (we now use containersMeta)
final rawItems = rawProposal['items']; final rawItems = rawProposal['items'];
if (rawItems is List) { if (rawItems is List) {
@@ -184,19 +209,64 @@ class AiEquipmentAssistantService {
} }
} }
final containersMeta = <AiEquipmentProposalContainer>[];
final rawContainers = rawProposal['containers']; final rawContainers = rawProposal['containers'];
if (rawContainers is List) { if (rawContainers is List) {
for (final rawContainer in rawContainers) { for (final rawContainer in rawContainers) {
if (rawContainer is String) {
final cid = rawContainer.toString().trim();
if (cid.isNotEmpty) {
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
}
continue;
}
if (rawContainer is! Map) continue; if (rawContainer is! Map) continue;
final container = Map<String, dynamic>.from(rawContainer); final container = Map<String, dynamic>.from(rawContainer);
final containerId = container['containerId']?.toString().trim() ?? ''; final containerId = container['containerId']?.toString().trim() ?? '';
if (containerId.isNotEmpty) { if (containerId.isEmpty) continue;
containerIds.add(containerId);
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
final equipmentIds = <String>[];
final matching = <String>[];
final missing = <String>[];
if (container['equipmentIds'] is List) {
for (final v in container['equipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) equipmentIds.add(s);
}
} }
if (container['matchingEquipmentIds'] is List) {
for (final v in container['matchingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) matching.add(s);
}
}
if (container['missingEquipmentIds'] is List) {
for (final v in container['missingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) missing.add(s);
}
}
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
containersMeta.add(AiEquipmentProposalContainer(
containerId: containerId,
rationale: rationale,
equipmentIds: equipmentIds,
matchingEquipmentIds: matching,
missingEquipmentIds: missing,
partial: partial,
available: available,
availabilityDetail: availabilityDetail,
));
} }
} }
if (proposalItems.isEmpty && containerIds.isEmpty) return null; if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
return AiEquipmentProposal( return AiEquipmentProposal(
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
@@ -204,7 +274,7 @@ class AiEquipmentAssistantService {
: 'Proposition matériel générée automatiquement.', : 'Proposition matériel générée automatiquement.',
items: proposalItems, items: proposalItems,
asEventEquipment: eventEquipmentList, asEventEquipment: eventEquipmentList,
containerIds: containerIds, containers: containersMeta,
); );
} }
} }
@@ -52,6 +52,7 @@ class _AiEquipmentAssistantDialogState
late List<EventEquipment> _workingEquipment; late List<EventEquipment> _workingEquipment;
AiEquipmentDocument? _selectedDocument; AiEquipmentDocument? _selectedDocument;
List<String> _sessionLogs = []; List<String> _sessionLogs = [];
Set<String> _selectedContainerIds = {};
@override @override
void initState() { void initState() {
@@ -134,6 +135,11 @@ class _AiEquipmentAssistantDialogState
_workingEquipment = List<EventEquipment>.from( _workingEquipment = List<EventEquipment>.from(
response.proposal!.asEventEquipment, response.proposal!.asEventEquipment,
); );
// Préselectionner les containers non partiels
_selectedContainerIds = {
for (final c in response.proposal!.containers)
if (!c.partial) c.containerId
};
} }
_sessionLogs.addAll(response.debugLogs); _sessionLogs.addAll(response.debugLogs);
_isLoading = false; _isLoading = false;
@@ -267,7 +273,7 @@ class _AiEquipmentAssistantDialogState
children: [ children: [
AppBar( AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: const Text('Assistant IA Logisticien'), title: const Text('(BETA) Assistant IA Logisticien'),
actions: [ actions: [
if (_sessionLogs.isNotEmpty) if (_sessionLogs.isNotEmpty)
IconButton( IconButton(
@@ -422,13 +428,13 @@ class _AiEquipmentAssistantDialogState
bottomLeft: Radius.circular(message.isUser ? 16 : 4), bottomLeft: Radius.circular(message.isUser ? 16 : 4),
bottomRight: Radius.circular(message.isUser ? 4 : 16), bottomRight: Radius.circular(message.isUser ? 4 : 16),
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.05), color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4, blurRadius: 10,
offset: const Offset(0, 2), offset: const Offset(0, 4),
), ),
], ],
border: border:
message.isUser ? null : Border.all(color: Colors.grey.shade200), message.isUser ? null : Border.all(color: Colors.grey.shade200),
), ),
@@ -488,11 +494,11 @@ class _AiEquipmentAssistantDialogState
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)), border: Border.all(color: color.withValues(alpha: 0.3)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -500,11 +506,13 @@ class _AiEquipmentAssistantDialogState
children: [ children: [
Icon(icon, size: 18, color: color), Icon(icon, size: 18, color: color),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
title.replaceAll(':', '').trim(), child: Text(
style: TextStyle( title.replaceAll(':', '').trim(),
fontWeight: FontWeight.bold, style: TextStyle(
color: color, fontWeight: FontWeight.bold,
color: color,
),
), ),
), ),
], ],
@@ -525,7 +533,10 @@ class _AiEquipmentAssistantDialogState
if (_latestProposal == null) return; if (_latestProposal == null) return;
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment); List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
List<String> containerIds = List.from(_latestProposal!.containerIds); // Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets)
final List<String> containerIds = _selectedContainerIds.isNotEmpty
? _selectedContainerIds.toList()
: List.from(_latestProposal!.containerIds);
if (excludeAlternatives) { if (excludeAlternatives) {
// On utilise la liste des items d'origine pour savoir lesquels exclure // On utilise la liste des items d'origine pour savoir lesquels exclure
@@ -558,9 +569,9 @@ class _AiEquipmentAssistantDialogState
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxHeight: 280), constraints: const BoxConstraints(maxHeight: 280),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.indigo.shade50, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.indigo.shade200), border: Border.all(color: Colors.grey.shade300),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.05), color: Colors.black.withValues(alpha: 0.05),
@@ -576,12 +587,14 @@ class _AiEquipmentAssistantDialogState
children: [ children: [
const Icon(Icons.assignment_turned_in, color: Colors.indigo), const Icon(Icons.assignment_turned_in, color: Colors.indigo),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( const Expanded(
'Récapitulatif de la proposition IA', child: Text(
style: TextStyle( 'Récapitulatif de la proposition IA',
fontWeight: FontWeight.bold, style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold,
color: Colors.indigo, fontSize: 16,
color: Colors.indigo,
),
), ),
), ),
], ],
@@ -633,21 +646,58 @@ class _AiEquipmentAssistantDialogState
); );
}), }),
], ],
if (proposal.containerIds.isNotEmpty) ...[ if (proposal.containers.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( const Text(
'Fly-cases & Boîtes :', 'Fly-cases & Boîtes :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
...proposal.containerIds.map((id) { ...proposal.containers.map((c) {
final isPartial = c.partial;
final isSelected = _selectedContainerIds.contains(c.containerId);
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4), padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.inventory_2_outlined, size: 14, color: Colors.indigo), Icon(
Icons.inventory_2_outlined,
size: 14,
color: c.available == false ? Colors.red : Colors.indigo,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(id, style: const TextStyle(fontWeight: FontWeight.w500)), Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))),
if (c.available == false)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(Icons.block, color: Colors.red.shade700, size: 14),
),
],
),
if (isPartial) ...[
const SizedBox(height: 4),
Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
],
],
),
),
const SizedBox(width: 8),
if (isPartial)
Checkbox(
value: isSelected,
onChanged: (v) {
setState(() {
if (v == true) _selectedContainerIds.add(c.containerId);
else _selectedContainerIds.remove(c.containerId);
});
},
),
], ],
), ),
); );