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
lib/config/env.dev.dart
functions/.env
functions/.env.local
+379 -31
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,40 +260,27 @@ 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 results = [];
snapshot.docs.forEach((doc) => {
const docs = snapshot.docs.map((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({
return {
id: doc.id,
name: data.name || doc.id,
name: String(data.name || doc.id),
category: data.category || '',
subCategory: data.subCategory || '',
brand: data.brand || null,
@@ -267,9 +288,68 @@ async function toolSearchEquipment(query) {
status: data.status || '',
availableQuantity: data.availableQuantity ?? null,
totalQuantity: data.totalQuantity ?? null,
};
});
const results = [];
// 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 });
}
+5 -2
View File
@@ -3,8 +3,11 @@
* Architecture backend sécurisée avec authentification et permissions
*/
// Charger les variables d'environnement depuis .env
require('dotenv').config();
// Charger les variables d'environnement depuis .env.local (développement)
// 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 { onSchedule } = require("firebase-functions/v2/scheduler");
+9 -5
View File
@@ -13,7 +13,7 @@
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3",
"firebase-functions": "^7.2.5",
"handlebars": "^4.7.8",
"nodemailer": "^6.10.1"
},
@@ -3364,9 +3364,9 @@
}
},
"node_modules/firebase-functions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3385,7 +3385,8 @@
"peerDependencies": {
"@apollo/server": "^5.2.0",
"@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": {
"@apollo/server": {
@@ -3393,6 +3394,9 @@
},
"@as-integrations/express4": {
"optional": true
},
"graphql": {
"optional": true
}
}
},
+1 -1
View File
@@ -21,7 +21,7 @@
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3",
"firebase-functions": "^7.2.5",
"handlebars": "^4.7.8",
"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.
class AiEquipmentProposal {
final String summary;
@@ -46,14 +69,16 @@ class AiEquipmentProposal {
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
final List<EventEquipment> asEventEquipment;
/// IDs des containers (flight cases) proposés par l'IA.
final List<String> containerIds;
/// Containers (métadonnées) proposés par l'IA.
final List<AiEquipmentProposalContainer> containers;
List<String> get containerIds => containers.map((c) => c.containerId).toList();
const AiEquipmentProposal({
required this.summary,
required this.items,
required this.asEventEquipment,
required this.containerIds,
required this.containers,
});
}
@@ -156,7 +181,7 @@ class AiEquipmentAssistantService {
final proposalItems = <AiEquipmentProposalItem>[];
final eventEquipmentList = <EventEquipment>[];
final containerIds = <String>[];
// legacy containerIds variable removed (we now use containersMeta)
final rawItems = rawProposal['items'];
if (rawItems is List) {
@@ -184,19 +209,64 @@ class AiEquipmentAssistantService {
}
}
final containersMeta = <AiEquipmentProposalContainer>[];
final rawContainers = rawProposal['containers'];
if (rawContainers is List) {
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;
final container = Map<String, dynamic>.from(rawContainer);
final containerId = container['containerId']?.toString().trim() ?? '';
if (containerId.isNotEmpty) {
containerIds.add(containerId);
if (containerId.isEmpty) continue;
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);
}
}
if (proposalItems.isEmpty && containerIds.isEmpty) return null;
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 && containersMeta.isEmpty) return null;
return AiEquipmentProposal(
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
@@ -204,7 +274,7 @@ class AiEquipmentAssistantService {
: 'Proposition matériel générée automatiquement.',
items: proposalItems,
asEventEquipment: eventEquipmentList,
containerIds: containerIds,
containers: containersMeta,
);
}
}
@@ -52,6 +52,7 @@ class _AiEquipmentAssistantDialogState
late List<EventEquipment> _workingEquipment;
AiEquipmentDocument? _selectedDocument;
List<String> _sessionLogs = [];
Set<String> _selectedContainerIds = {};
@override
void initState() {
@@ -134,6 +135,11 @@ class _AiEquipmentAssistantDialogState
_workingEquipment = List<EventEquipment>.from(
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);
_isLoading = false;
@@ -267,7 +273,7 @@ class _AiEquipmentAssistantDialogState
children: [
AppBar(
automaticallyImplyLeading: false,
title: const Text('Assistant IA Logisticien'),
title: const Text('(BETA) Assistant IA Logisticien'),
actions: [
if (_sessionLogs.isNotEmpty)
IconButton(
@@ -425,8 +431,8 @@ class _AiEquipmentAssistantDialogState
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border:
@@ -500,13 +506,15 @@ class _AiEquipmentAssistantDialogState
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Text(
Expanded(
child: Text(
title.replaceAll(':', '').trim(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
),
],
),
if (content.trim().isNotEmpty) ...[
@@ -525,7 +533,10 @@ class _AiEquipmentAssistantDialogState
if (_latestProposal == null) return;
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) {
// On utilise la liste des items d'origine pour savoir lesquels exclure
@@ -558,9 +569,9 @@ class _AiEquipmentAssistantDialogState
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxHeight: 280),
decoration: BoxDecoration(
color: Colors.indigo.shade50,
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.indigo.shade200),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
@@ -576,7 +587,8 @@ class _AiEquipmentAssistantDialogState
children: [
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
const SizedBox(width: 12),
const Text(
const Expanded(
child: Text(
'Récapitulatif de la proposition IA',
style: TextStyle(
fontWeight: FontWeight.bold,
@@ -584,6 +596,7 @@ class _AiEquipmentAssistantDialogState
color: Colors.indigo,
),
),
),
],
),
const SizedBox(height: 12),
@@ -633,21 +646,58 @@ class _AiEquipmentAssistantDialogState
);
}),
],
if (proposal.containerIds.isNotEmpty) ...[
if (proposal.containers.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Fly-cases & Boîtes :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
...proposal.containerIds.map((id) {
...proposal.containers.map((c) {
final isPartial = c.partial;
final isSelected = _selectedContainerIds.contains(c.containerId);
return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row(
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),
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);
});
},
),
],
),
);