feat: (BETA) Amélioration de l'assistant IA logisticien (Gemini) et support des documents
- **Amélioration de l'IA (Cloud Functions)** :
- Mise à jour du modèle vers `gemini-3.1-flash-lite` et augmentation de la limite des résultats de recherche à 50.
- Optimisation de la gestion des outils : augmentation du nombre d'appels simultanés (`MAX_TOOL_CALLS_PER_ITERATION`) à 40.
- Refonte du système de recherche d'équipements avec une stratégie en deux passes (recherche précise puis catégorielle avec normalisation agressive).
- Nouvelles consignes strictes pour la gestion des unités uniques (quantité de 1 par ID) et priorité aux flight cases (containers).
- Ajout d'une gestion de retry avec temporisation pour les erreurs de quota (429) et de surcharge (503).
- Support de l'analyse de documents joints (devis, listes) envoyés en `inlineData`.
- **Interface de l'Assistant (`AiEquipmentAssistantDialog`)** :
- Ajout de la possibilité de joindre des documents (PDF, images, texte) via `FilePicker` pour analyse par l'IA.
- Implémentation d'une vue de logs de debug détaillée pour suivre le raisonnement de l'IA et les appels d'outils.
- Amélioration visuelle de la discussion : bulles de message stylisées et structuration automatique des réponses (sections "Matériel ajouté" vs "Matériel non trouvé").
- Nouvelles options de confirmation : "Tout ajouter" ou "Ajouter sans alternatives".
- **Modèles et Services** :
- Mise à jour de `EventEquipment` pour inclure un champ `rationale` (justification du choix de l'équipement).
- Correction dans `EventAssignedEquipmentSection` pour ajouter automatiquement les équipements enfants lors de l'ajout d'un container proposé par l'IA.
- Ajout de la gestion des logs et des documents dans `AiEquipmentAssistantService`.
- **UI Divers** :
- Mise à jour de `EquipmentFormPage` pour clarifier le comportement de l'identifiant (auto-génération recommandée).
This commit is contained in:
@@ -9,13 +9,13 @@ 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-preview';
|
const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de versio, celle ci existe
|
||||||
const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc';
|
const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc';
|
||||||
const MAX_TOOL_ITERATIONS = 20;
|
const MAX_TOOL_ITERATIONS = 20;
|
||||||
const PAST_EVENTS_LIMIT = 5;
|
const PAST_EVENTS_LIMIT = 5;
|
||||||
const SEARCH_RESULTS_LIMIT = 20;
|
const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle
|
||||||
const EVENT_SEARCH_SCAN_LIMIT = 100;
|
const EVENT_SEARCH_SCAN_LIMIT = 100;
|
||||||
const MAX_TOOL_CALLS_PER_ITERATION = 5;
|
const MAX_TOOL_CALLS_PER_ITERATION = 40;
|
||||||
const AVAILABILITY_EVENTS_SCAN_LIMIT = 500;
|
const AVAILABILITY_EVENTS_SCAN_LIMIT = 500;
|
||||||
const MAX_BATCH_AVAILABILITY_ITEMS = 50;
|
const MAX_BATCH_AVAILABILITY_ITEMS = 50;
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ const getDb = () => admin.firestore();
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Déclarations des tools Gemini
|
// Déclarations des tools Gemini
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const AI_TOOLS = [
|
const AI_TOOLS = [
|
||||||
{
|
{
|
||||||
functionDeclarations: [
|
functionDeclarations: [
|
||||||
@@ -167,33 +166,58 @@ const AI_TOOLS = [
|
|||||||
const SYSTEM_PROMPT = `Tu es un expert logisticien IA pour la gestion de materiel evenementiel.
|
const SYSTEM_PROMPT = `Tu es un expert logisticien IA pour la gestion de materiel evenementiel.
|
||||||
Tu dois proposer une liste de materiel et de flight cases adaptee a l evenement decrit.
|
Tu dois proposer une liste de materiel et de flight cases adaptee a l evenement decrit.
|
||||||
|
|
||||||
|
Regles sur les UNITES et QUANTITES (CRITIQUE) :
|
||||||
|
- La plupart des equipements (Lumiere, Son, Video, Structure) sont des UNITES UNIQUES. Chaque ID (ex: "BARRE_LED_#1") represente un seul appareil physique.
|
||||||
|
- Si l'utilisateur demande 6 barres LED, tu dois trouver et ajouter 6 IDs DIFFERENTS (ex: "BARRE_LED_#1", "BARRE_LED_#2", etc.) dans proposal.items, chacun avec "quantity": 1.
|
||||||
|
- Ne jamais mettre "quantity": 6 pour un item dont totalQuantity est 1 ou null.
|
||||||
|
- Les seules exceptions sont les categories "CONSUMABLE" (Consommable) et "CABLE" (Cable) qui peuvent avoir une quantite > 1 pour un seul ID.
|
||||||
|
|
||||||
|
Regles sur les flight cases (PRIORITAIRE) :
|
||||||
|
- Les flight cases (containers) sont le moyen privilege de transporter le materiel.
|
||||||
|
- Strategie :
|
||||||
|
1. Identifie d'abord tous les equipements individuels necessaires.
|
||||||
|
2. Appelle search_containers avec la liste de TOUS ces IDs d'equipements.
|
||||||
|
3. Si un ou plusieurs flight cases couvrent une partie ou la totalite du materiel :
|
||||||
|
- Ajoute les flight cases dans "proposal.containers".
|
||||||
|
- RETIRE les equipements contenus dans ces flight cases de ta liste "proposal.items".
|
||||||
|
- Les equipements restants (non contenus dans des flight cases) doivent rester dans "proposal.items".
|
||||||
|
- Un flight case est toujours preferable a des items individuels.
|
||||||
|
|
||||||
|
Regles de recherche (STRATEGIE EN DEUX PASSES) :
|
||||||
|
1. PREMIER PASSE (RECHERCHE PRECISE) :
|
||||||
|
- Cherche chaque item demande avec son nom complet (ex: "dB Technologies Ignenia IG5TR").
|
||||||
|
- Si ca ne donne rien, REESSAYE immediatement avec des mots-clefs plus courts (ex: "Ignenia", "IG5TR", "VIO S118").
|
||||||
|
- Sois perseverant : les noms en base sont souvent au format "MARQUE_MODELE_#1".
|
||||||
|
|
||||||
|
2. DEUXIEME PASSE (ANALYSE CONTEXTUELLE ET CATEGORIELLE) :
|
||||||
|
- Si la recherche precise echoue, analyse le contexte de l'item dans le devis ou la demande.
|
||||||
|
- Identifie la categorie probable (ex: "dB Technologies" -> "SONO" ou "SOUND", "Robert Juliat" -> "LUMIERE" ou "LIGHTING").
|
||||||
|
- Utilise list_equipment_by_category pour recuperer tout le materiel de cette categorie.
|
||||||
|
- Parcoure la liste recuperee pour trouver un item qui "match" logiquement (ex: l'utilisateur demande "Opera 15" mais l'identifiant est "DBTE_OPERA_15_#1").
|
||||||
|
- Si tu trouves une correspondance logique dans la liste de la categorie, utilise l'ID exact trouve en base.
|
||||||
|
|
||||||
|
Regles de transparence (OBLIGATOIRE) :
|
||||||
|
- Ton assistantMessage doit TOUJOURS contenir deux sections claires et distinctes si applicable :
|
||||||
|
1. "Matériel ajouté :" (Utilise exactement ce titre) suivi de la liste des équipements et flight cases que tu as réussi à trouver et qui sont disponibles. Explique brièvement pourquoi.
|
||||||
|
2. "Matériel non trouvé ou indisponible :" (Utilise exactement ce titre) suivi de la liste précise des éléments de la demande utilisateur que tu n'as pas pu intégrer. Pour chaque élément, précise s'il est "Absent de la base de données" ou "Indisponible (rupture de stock)".
|
||||||
|
- N'hésite pas à être pédagogue dans tes explications, mais garde ces titres de section exacts pour permettre un affichage structuré dans l'application.
|
||||||
|
|
||||||
Regles absolues :
|
Regles absolues :
|
||||||
- Tu ne dois JAMAIS ecrire en base de donnees.
|
- Tu ne dois JAMAIS ecrire en base de donnees.
|
||||||
- Utilise search_equipment pour trouver du materiel, check_availability_batch en priorite pour verifier la disponibilite, check_availability pour un cas isole, get_past_events pour t inspirer.
|
- Si l utilisateur cite un evenement precis (nom/date), appelle d abord search_event_reference.
|
||||||
- Si une recherche precise echoue, utilise list_equipment_by_category pour explorer les categories ou trouver corriger d'eventuelles fautes de frappe de l'utilisateur.
|
- Rend toujours la LISTE FINALE COMPLETE y compris le materiel deja assigne s'il est toujours pertinent.
|
||||||
- Si l utilisateur cite un evenement precis (nom/date), appelle d abord search_event_reference pour retrouver cet evenement et reutiliser son materiel ET ses flight cases.
|
- Verifie la disponibilite de CHAQUE equipement ET container propose via check_availability_batch.
|
||||||
- La sous-categorie du materiel est tres importante. Prends-la en compte en priorite.
|
- Si un materiel est en rupture de stock, cherche une alternative (autre ID du meme modele ou autre modele similaire). Si aucune alternative n'est trouvee, deplace l'item dans la section "indisponible".
|
||||||
- Sois libre d'ajouter du materiel pertinent par rapport aux options ou a la description de l'evenement si cela te semble justifie. Explique tes choix dans rationale.
|
|
||||||
|
|
||||||
Regles sur les flight cases (PRIORITAIRES) :
|
|
||||||
- Apres avoir identifie les equipements necessaires, appelle TOUJOURS search_containers avec la liste de leurs IDs.
|
|
||||||
- Si un flight case contient tous les equipements necessaires, propose le flight case uniquement. Ne mets PAS ces equipements dans items.
|
|
||||||
- Si un flight case contient plus d equipements que necessaire (ex: 5 blinders dans le case mais on en veut 2), propose les equipements individuellement, pas le flight case.
|
|
||||||
- N ajoute du materiel individuel dans items que pour les equipements sans flight case couvrant exactement le besoin.
|
|
||||||
- Si un equipement est indisponible, cherche une alternative via search_equipment, verifie sa dispo, puis search_containers sur l alternative.
|
|
||||||
- Verifie la disponibilite de CHAQUE equipement ET container propose.
|
|
||||||
- Quand une proposition courante existe deja, renvoie toujours la LISTE FINALE COMPLETE.
|
|
||||||
|
|
||||||
Reponse finale : du JSON valide strict, sans markdown, avec ce format exact :
|
Reponse finale : du JSON valide strict, sans markdown, avec ce format exact :
|
||||||
{"assistantMessage":"...","proposal":{"summary":"...","containers":[{"containerId":"ID-EXACT","rationale":"..."}],"items":[{"equipmentId":"ID-EXACT","quantity":1,"rationale":"..."}]}}
|
{"assistantMessage":"...","proposal":{"summary":"...","containers":[{"containerId":"ID-EXACT","rationale":"..."}],"items":[{"equipmentId":"ID-EXACT","quantity":1,"rationale":"..."}]}}
|
||||||
- containers : flight cases proposes (les equipements couverts par un container ne doivent pas etre dans items)
|
- containers : flight cases proposes.
|
||||||
- items : equipements individuels non couverts par un container
|
- items : equipements individuels NON contenus dans les flight cases proposes ci-dessus.
|
||||||
- Si impossible, renvoie proposal a null.`;
|
- Si impossible, renvoie proposal a null.`;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Implémentation des tools côté serveur (Firestore Admin SDK)
|
// Implémentation des tools côté serveur (Firestore Admin SDK)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recherche des équipements dans Firestore par mot-clé.
|
* Recherche des équipements dans Firestore par mot-clé.
|
||||||
*/
|
*/
|
||||||
@@ -202,26 +226,37 @@ async function toolSearchEquipment(query) {
|
|||||||
return { query, count: 0, results: [] };
|
return { query, count: 0, results: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
// Normalisation agressive pour la recherche (sans accents, sans ponctuation)
|
||||||
|
const normalize = (str) =>
|
||||||
|
(str || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
const snapshot = await getDb().collection('equipments').limit(200).get();
|
const normalizedQuery = normalize(query);
|
||||||
|
|
||||||
|
const snapshot = await getDb().collection('equipments').limit(500).get();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
snapshot.docs.forEach((doc) => {
|
snapshot.docs.forEach((doc) => {
|
||||||
const data = doc.data();
|
const data = doc.data();
|
||||||
const searchableText = [
|
const searchableFields = [
|
||||||
data.name,
|
data.name,
|
||||||
doc.id,
|
doc.id,
|
||||||
data.model,
|
data.model,
|
||||||
data.brand,
|
data.brand,
|
||||||
data.category,
|
data.category,
|
||||||
data.subCategory,
|
data.subCategory,
|
||||||
]
|
];
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (searchableText.includes(normalizedQuery)) {
|
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({
|
results.push({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: data.name || doc.id,
|
name: data.name || doc.id,
|
||||||
@@ -837,17 +872,17 @@ async function toolSearchEventReference(query, dateHint) {
|
|||||||
async function toolListEquipmentByCategory(category, subCategory) {
|
async function toolListEquipmentByCategory(category, subCategory) {
|
||||||
let queryDb = getDb().collection('equipments');
|
let queryDb = getDb().collection('equipments');
|
||||||
|
|
||||||
if (category) {
|
// Si on a des critères précis, on filtre. Sinon on scanne pour matcher flou.
|
||||||
queryDb = queryDb.where('category', '==', category);
|
const snapshot = await queryDb.limit(1000).get();
|
||||||
}
|
|
||||||
|
|
||||||
if (subCategory) {
|
const normalize = (str) =>
|
||||||
queryDb = queryDb.where('subCategory', '==', subCategory);
|
(str || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await queryDb.limit(50).get();
|
const normCat = category ? normalize(category) : null;
|
||||||
|
const normSub = subCategory ? normalize(subCategory) : null;
|
||||||
|
|
||||||
const results = snapshot.docs.map((doc) => {
|
const results = snapshot.docs
|
||||||
|
.map((doc) => {
|
||||||
const data = doc.data();
|
const data = doc.data();
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
@@ -858,7 +893,14 @@ async function toolListEquipmentByCategory(category, subCategory) {
|
|||||||
model: data.model || null,
|
model: data.model || null,
|
||||||
status: data.status || '',
|
status: data.status || '',
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
.filter(item => {
|
||||||
|
let match = true;
|
||||||
|
if (normCat) match = match && normalize(item.category).includes(normCat);
|
||||||
|
if (normSub) match = match && normalize(item.subCategory).includes(normSub);
|
||||||
|
return match;
|
||||||
|
})
|
||||||
|
.slice(0, 50);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
category: category || 'all',
|
category: category || 'all',
|
||||||
@@ -922,7 +964,6 @@ async function executeTool(toolCall, excludeEventId, sharedContext) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Gestionnaire principal
|
// Gestionnaire principal
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit le prompt utilisateur avec le contexte de l'événement.
|
* Construit le prompt utilisateur avec le contexte de l'événement.
|
||||||
*/
|
*/
|
||||||
@@ -965,6 +1006,7 @@ function buildUserPrompt({
|
|||||||
'Demande :',
|
'Demande :',
|
||||||
finalMessage,
|
finalMessage,
|
||||||
'',
|
'',
|
||||||
|
'Si un fichier (document/devis) a été joint à ma demande (sous forme inlineData by Gemini), tu dois absolument l analyser, en deduire le materiel demande, et l ajouter a ta proposition.',
|
||||||
'Si la demande cite un evenement precis (nom/date), commence par search_event_reference avant de proposer du materiel.',
|
'Si la demande cite un evenement precis (nom/date), commence par search_event_reference avant de proposer du materiel.',
|
||||||
'Si une proposition courante existe, traite-la comme base de travail et renvoie toujours la liste finale complete apres modification.',
|
'Si une proposition courante existe, traite-la comme base de travail et renvoie toujours la liste finale complete apres modification.',
|
||||||
'Rappel : vérifier la disponibilité avant de recommander. Privilégier check_availability_batch pour contrôler plusieurs équipements en un appel.',
|
'Rappel : vérifier la disponibilité avant de recommander. Privilégier check_availability_batch pour contrôler plusieurs équipements en un appel.',
|
||||||
@@ -972,19 +1014,56 @@ function buildUserPrompt({
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un tableau de parts pour Gemini permettant d'inclure des documents.
|
||||||
|
*/
|
||||||
|
function buildMessageParts(params, logDebug) {
|
||||||
|
const textPrompt = buildUserPrompt(params);
|
||||||
|
const parts = [{ text: textPrompt }];
|
||||||
|
|
||||||
|
if (params.document && params.document.data && params.document.mimeType) {
|
||||||
|
if (logDebug) logDebug(`[AI] Attaching document ${params.document.mimeType}`);
|
||||||
|
else logger.info(`[AI] Attaching document ${params.document.mimeType}`);
|
||||||
|
parts.push({
|
||||||
|
inlineData: {
|
||||||
|
data: params.document.data,
|
||||||
|
mimeType: params.document.mimeType,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const fileNameInfo = params.document.fileName ? ` (nom du fichier: ${params.document.fileName})` : ``;
|
||||||
|
parts.push({
|
||||||
|
text: `J'ai joint un document de type ${params.document.mimeType}${fileNameInfo}. Merci d'extraire le materiel et de l'ajouter a la proposition.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrait un texte exploitable depuis la réponse Gemini, même si response.text() est vide.
|
* Extrait un texte exploitable depuis la réponse Gemini, même si response.text() est vide.
|
||||||
*/
|
*/
|
||||||
function extractResponseText(modelResponse) {
|
function extractResponseText(modelResponse, logDebug, logError) {
|
||||||
if (!modelResponse) return '';
|
const info = logDebug || logger.info.bind(logger);
|
||||||
|
const warn = logError || logger.warn.bind(logger);
|
||||||
|
|
||||||
|
if (!modelResponse) {
|
||||||
|
warn('[AI] extractResponseText: modelResponse est vide ou undefined');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
info('[AI] extractResponseText: analyse de la réponse brut', {
|
||||||
|
hasTextFunc: typeof modelResponse.text === 'function',
|
||||||
|
candidatesCount: modelResponse.candidates?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const directText = modelResponse.text?.();
|
const directText = modelResponse.text?.();
|
||||||
if (directText && directText.trim().length > 0) {
|
if (directText && directText.trim().length > 0) {
|
||||||
|
info('[AI] extractResponseText: Texte extrait via modelResponse.text()');
|
||||||
return directText.trim();
|
return directText.trim();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
// Fallback via candidates/parts ci-dessous.
|
warn('[AI] extractResponseText: erreur lors de l appel a modelResponse.text()', err?.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = Array.isArray(modelResponse.candidates) ? modelResponse.candidates : [];
|
const candidates = Array.isArray(modelResponse.candidates) ? modelResponse.candidates : [];
|
||||||
@@ -1001,17 +1080,24 @@ function extractResponseText(modelResponse) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return textParts.join('\n').trim();
|
const fallbackText = textParts.join('\n').trim();
|
||||||
|
info(`[AI] extractResponseText: Texte extrait en fallback (longueur: ${fallbackText.length})`);
|
||||||
|
return fallbackText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrait et parse le JSON de la réponse IA.
|
* Extrait et parse le JSON de la réponse IA.
|
||||||
*/
|
*/
|
||||||
function parseAiResponse(rawText) {
|
function parseAiResponse(rawText, logDebug, logError) {
|
||||||
|
const info = logDebug || logger.info.bind(logger);
|
||||||
|
const err = logError || logger.error.bind(logger);
|
||||||
|
|
||||||
if (!rawText || rawText.trim().length === 0) {
|
if (!rawText || rawText.trim().length === 0) {
|
||||||
|
err('[AI] parseAiResponse: rawText recu est vide !');
|
||||||
throw new Error('Réponse IA vide.');
|
throw new Error('Réponse IA vide.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info('[AI] parseAiResponse tentatives de parsing sur le texte (apercu):', rawText.substring(0, 100) + '...');
|
||||||
// Tentative directe
|
// Tentative directe
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawText.trim());
|
const parsed = JSON.parse(rawText.trim());
|
||||||
@@ -1045,10 +1131,55 @@ function parseAiResponse(rawText) {
|
|||||||
throw new Error('JSON IA invalide ou introuvable dans la réponse.');
|
throw new Error('JSON IA invalide ou introuvable dans la réponse.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un message à Gemini avec une temporisation en cas de Rate Limit (429).
|
||||||
|
*/
|
||||||
|
async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) {
|
||||||
|
const maxRetries = 5;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await chat.sendMessage(messagePayload);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.status || error?.response?.status;
|
||||||
|
const errorMessage = error.message || '';
|
||||||
|
const isRateLimit = status === 429 || errorMessage.includes('429') || errorMessage.includes('quota');
|
||||||
|
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
|
||||||
|
|
||||||
|
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}...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler principal de la Cloud Function aiEquipmentProposal.
|
* Handler principal de la Cloud Function aiEquipmentProposal.
|
||||||
*/
|
*/
|
||||||
async function handleAiEquipmentProposal(req, res) {
|
async function handleAiEquipmentProposal(req, res) {
|
||||||
|
const debugLogs = [];
|
||||||
|
const logDebug = (msg, data) => {
|
||||||
|
const dataStr = data ? ` | ${JSON.stringify(data)}` : '';
|
||||||
|
debugLogs.push(`[INFO] ${msg}${dataStr}`);
|
||||||
|
logger.info(msg, data);
|
||||||
|
};
|
||||||
|
const logError = (msg, data) => {
|
||||||
|
const dataStr = data ? ` | ${JSON.stringify(data)}` : '';
|
||||||
|
debugLogs.push(`[ERROR] ${msg}${dataStr}`);
|
||||||
|
logger.error(msg, data);
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
eventName,
|
eventName,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
@@ -1062,6 +1193,7 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
currentEquipment = [],
|
currentEquipment = [],
|
||||||
workingProposal = [],
|
workingProposal = [],
|
||||||
excludeEventId,
|
excludeEventId,
|
||||||
|
document,
|
||||||
} = req.body.data || {};
|
} = req.body.data || {};
|
||||||
|
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
@@ -1075,9 +1207,11 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
systemInstruction: SYSTEM_PROMPT,
|
systemInstruction: SYSTEM_PROMPT,
|
||||||
tools: AI_TOOLS,
|
tools: AI_TOOLS,
|
||||||
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
||||||
generationConfig: { temperature: 0.2, responseMimeType: 'application/json' },
|
generationConfig: { temperature: 0.2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logDebug('[AI] Reconstruction de l historique de conversation. Elements :', history?.length || 0);
|
||||||
|
|
||||||
// Reconstruire l'historique de conversation
|
// Reconstruire l'historique de conversation
|
||||||
const chatHistory = (history || [])
|
const chatHistory = (history || [])
|
||||||
.filter((turn) => turn.text && turn.text.trim().length > 0)
|
.filter((turn) => turn.text && turn.text.trim().length > 0)
|
||||||
@@ -1086,13 +1220,15 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
parts: [{ text: turn.text.trim() }],
|
parts: [{ text: turn.text.trim() }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
logDebug('[AI] Historique formaté :', chatHistory);
|
||||||
|
|
||||||
const chat = model.startChat({ history: chatHistory });
|
const chat = model.startChat({ history: chatHistory });
|
||||||
const toolResultCache = new Map();
|
const toolResultCache = new Map();
|
||||||
const sharedToolContext = {
|
const sharedToolContext = {
|
||||||
availabilityCandidatesByWindow: new Map(),
|
availabilityCandidatesByWindow: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userPrompt = buildUserPrompt({
|
const messageParts = buildMessageParts({
|
||||||
userMessage,
|
userMessage,
|
||||||
eventName,
|
eventName,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
@@ -1103,25 +1239,38 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
eventOptions,
|
eventOptions,
|
||||||
currentEquipment,
|
currentEquipment,
|
||||||
workingProposal,
|
workingProposal,
|
||||||
|
document,
|
||||||
|
}, logDebug);
|
||||||
|
|
||||||
|
// Sécuriser les logs de base64
|
||||||
|
const safeMessagePartsForLogs = JSON.parse(JSON.stringify(messageParts));
|
||||||
|
safeMessagePartsForLogs.forEach(part => {
|
||||||
|
if (part.inlineData && part.inlineData.data) {
|
||||||
|
part.inlineData.data = `<BASE64 TRONQUE : ${part.inlineData.data.length} chars>`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('[AI] Starting conversation', { eventTypeId, startDate, endDate });
|
logDebug('[AI] Starting conversation. Parts envoys:', safeMessagePartsForLogs);
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await chat.sendMessage(userPrompt);
|
response = await sendMessageWithRetry(chat, messageParts, logDebug, logError);
|
||||||
|
logDebug('[AI] Premiere reponse recue du sendMessage.');
|
||||||
|
|
||||||
// Boucle de function calling avec cache local.
|
// Boucle de function calling avec cache local.
|
||||||
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
|
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
|
||||||
const functionCalls = response.response.functionCalls();
|
const functionCalls = response.response.functionCalls();
|
||||||
|
|
||||||
if (!functionCalls || functionCalls.length === 0) {
|
if (!functionCalls || functionCalls.length === 0) {
|
||||||
|
logDebug(`[AI] (Iteration ${iteration + 1}) Fin des tool calls, l IA a retourné une réponse (ou un appel vide).`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limitedCalls = functionCalls.slice(0, MAX_TOOL_CALLS_PER_ITERATION);
|
logDebug(`[AI] (Iteration ${iteration + 1}) Tool calls demands par l IA:`, functionCalls);
|
||||||
const availabilityCalls = limitedCalls.filter(
|
|
||||||
|
const callsToProcess = functionCalls.slice(0, MAX_TOOL_CALLS_PER_ITERATION);
|
||||||
|
const availabilityCalls = callsToProcess.filter(
|
||||||
(call) => call.name === 'check_availability'
|
(call) => call.name === 'check_availability'
|
||||||
&& call.args?.equipmentId
|
&& call.args?.equipmentId
|
||||||
&& call.args?.startDate
|
&& call.args?.startDate
|
||||||
@@ -1164,7 +1313,7 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
batchResult.results.map((item) => [String(item.equipmentId), item]),
|
batchResult.results.map((item) => [String(item.equipmentId), item]),
|
||||||
);
|
);
|
||||||
batchWindow = { startDate: firstStartDate, endDate: firstEndDate };
|
batchWindow = { startDate: firstStartDate, endDate: firstEndDate };
|
||||||
logger.info('[AI] Consolidated check_availability calls into one batch call', {
|
logDebug('[AI] Consolidated check_availability calls into one batch call', {
|
||||||
iteration: iteration + 1,
|
iteration: iteration + 1,
|
||||||
itemCount: equipmentIds.length,
|
itemCount: equipmentIds.length,
|
||||||
});
|
});
|
||||||
@@ -1172,10 +1321,19 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[AI] Tool calls (iteration ${iteration + 1}):`, limitedCalls.map((c) => c.name));
|
logDebug(`[AI] Tool calls executes. Preparation du renvoi des resultats...`);
|
||||||
|
|
||||||
const toolResults = await Promise.all(
|
const toolResults = await Promise.all(
|
||||||
limitedCalls.map(async (toolCall) => {
|
functionCalls.map(async (toolCall, ind) => {
|
||||||
|
if (ind >= MAX_TOOL_CALLS_PER_ITERATION) {
|
||||||
|
return {
|
||||||
|
functionResponse: {
|
||||||
|
name: toolCall.name,
|
||||||
|
response: { error: 'Limite de tool calls simultanes atteinte. (Ignore)' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const cacheKey = buildToolCacheKey(toolCall.name, toolCall.args);
|
const cacheKey = buildToolCacheKey(toolCall.name, toolCall.args);
|
||||||
if (toolResultCache.has(cacheKey)) {
|
if (toolResultCache.has(cacheKey)) {
|
||||||
return {
|
return {
|
||||||
@@ -1205,7 +1363,7 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
toolResult = await executeTool(toolCall, excludeEventId, sharedToolContext);
|
toolResult = await executeTool(toolCall, excludeEventId, sharedToolContext);
|
||||||
}
|
}
|
||||||
} catch (toolError) {
|
} catch (toolError) {
|
||||||
logger.warn('[AI] Tool call failed, returning degraded tool response', {
|
logError('[AI] Tool call failed, returning degraded tool response', {
|
||||||
tool: toolCall.name,
|
tool: toolCall.name,
|
||||||
message: toolError?.message || 'unknown',
|
message: toolError?.message || 'unknown',
|
||||||
});
|
});
|
||||||
@@ -1214,6 +1372,8 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
|
|
||||||
toolResultCache.set(cacheKey, toolResult);
|
toolResultCache.set(cacheKey, toolResult);
|
||||||
|
|
||||||
|
logDebug(`[AI] Resultat du tool ${toolCall.name} (apercu):`, JSON.stringify(toolResult).substring(0, 150) + '...');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
name: toolCall.name,
|
name: toolCall.name,
|
||||||
@@ -1223,24 +1383,30 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
response = await chat.sendMessage(toolResults);
|
logDebug(`[AI] Envoi de ${toolResults.length} resultats au modèle pour la suite de la conversation.`);
|
||||||
|
response = await sendMessageWithRetry(chat, toolResults, logDebug, logError);
|
||||||
|
logDebug(`[AI] Reponse recue du modele suite aux resultats des tools.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[AI] Conversation timeout/error', {
|
logError('[AI] Conversation timeout/error, DETAIL COMPLET:', {
|
||||||
message: error?.message || 'unknown',
|
message: error?.message || 'unknown',
|
||||||
|
status: error?.status,
|
||||||
|
details: error?.details,
|
||||||
stack: error?.stack
|
stack: error?.stack
|
||||||
});
|
});
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
assistantMessage: 'Je suis désolé, je n\'ai pas réussi à traiter votre demande en raison d\'un temps trop long ou d\'une erreur technique. Veuillez réessayer avec des requêtes plus spécifiques.',
|
assistantMessage: 'Je suis désolé, je n\'ai pas réussi à traiter votre demande en raison d\'un temps trop long ou d\'une erreur technique. Veuillez réessayer avec des requêtes plus spécifiques.',
|
||||||
proposal: null,
|
proposal: null,
|
||||||
|
debugLogs,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawText = extractResponseText(response.response);
|
logDebug('[AI] Structure finale du response.response (apercu):', JSON.stringify(response.response).substring(0, 300) + '...');
|
||||||
logger.info('[AI] Raw response received, parsing...', {
|
const rawText = extractResponseText(response.response, logDebug, logError);
|
||||||
|
logDebug('[AI] Raw response extracted:', {
|
||||||
hasText: rawText.length > 0,
|
hasText: rawText.length > 0,
|
||||||
candidateCount: response.response?.candidates?.length || 0,
|
rawTextExcerpt: rawText.substring(0, 200) + '...'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fallback non bloquant: éviter un 500 quand Gemini ne renvoie pas de texte exploitable.
|
// Fallback non bloquant: éviter un 500 quand Gemini ne renvoie pas de texte exploitable.
|
||||||
@@ -1248,18 +1414,20 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
assistantMessage: 'Je n\'ai pas pu générer une réponse exploitable pour le moment. Réessaie avec une consigne plus précise.',
|
assistantMessage: 'Je n\'ai pas pu générer une réponse exploitable pour le moment. Réessaie avec une consigne plus précise.',
|
||||||
proposal: null,
|
proposal: null,
|
||||||
|
debugLogs,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = parseAiResponse(rawText);
|
payload = parseAiResponse(rawText, logDebug, logError);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[AI] JSON parsing failed, returning degraded response', error);
|
logError('[AI] JSON parsing failed, returning degraded response', error?.message);
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
assistantMessage: rawText,
|
assistantMessage: rawText,
|
||||||
proposal: null,
|
proposal: null,
|
||||||
|
debugLogs,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1296,8 +1464,7 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ assistantMessage, proposal });
|
res.status(200).json({ assistantMessage, proposal, debugLogs });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handleAiEquipmentProposal };
|
module.exports = { handleAiEquipmentProposal };
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
|
|||||||
class EventEquipment {
|
class EventEquipment {
|
||||||
final String equipmentId; // ID de l'équipement
|
final String equipmentId; // ID de l'équipement
|
||||||
final int quantity; // Quantité initiale assignée
|
final int quantity; // Quantité initiale assignée
|
||||||
|
final String? rationale; // Explication/Justification (ex: IA alternative)
|
||||||
final bool isPrepared; // Validé en préparation
|
final bool isPrepared; // Validé en préparation
|
||||||
final bool isLoaded; // Validé au chargement
|
final bool isLoaded; // Validé au chargement
|
||||||
final bool isUnloaded; // Validé au déchargement
|
final bool isUnloaded; // Validé au déchargement
|
||||||
@@ -194,6 +195,7 @@ class EventEquipment {
|
|||||||
EventEquipment({
|
EventEquipment({
|
||||||
required this.equipmentId,
|
required this.equipmentId,
|
||||||
this.quantity = 1,
|
this.quantity = 1,
|
||||||
|
this.rationale,
|
||||||
this.isPrepared = false,
|
this.isPrepared = false,
|
||||||
this.isLoaded = false,
|
this.isLoaded = false,
|
||||||
this.isUnloaded = false,
|
this.isUnloaded = false,
|
||||||
@@ -212,6 +214,7 @@ class EventEquipment {
|
|||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: map['equipmentId'] ?? '',
|
equipmentId: map['equipmentId'] ?? '',
|
||||||
quantity: map['quantity'] ?? 1,
|
quantity: map['quantity'] ?? 1,
|
||||||
|
rationale: map['rationale'],
|
||||||
isPrepared: map['isPrepared'] ?? false,
|
isPrepared: map['isPrepared'] ?? false,
|
||||||
isLoaded: map['isLoaded'] ?? false,
|
isLoaded: map['isLoaded'] ?? false,
|
||||||
isUnloaded: map['isUnloaded'] ?? false,
|
isUnloaded: map['isUnloaded'] ?? false,
|
||||||
@@ -231,6 +234,7 @@ class EventEquipment {
|
|||||||
return {
|
return {
|
||||||
'equipmentId': equipmentId,
|
'equipmentId': equipmentId,
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
|
'rationale': rationale,
|
||||||
'isPrepared': isPrepared,
|
'isPrepared': isPrepared,
|
||||||
'isLoaded': isLoaded,
|
'isLoaded': isLoaded,
|
||||||
'isUnloaded': isUnloaded,
|
'isUnloaded': isUnloaded,
|
||||||
@@ -249,6 +253,7 @@ class EventEquipment {
|
|||||||
EventEquipment copyWith({
|
EventEquipment copyWith({
|
||||||
String? equipmentId,
|
String? equipmentId,
|
||||||
int? quantity,
|
int? quantity,
|
||||||
|
String? rationale,
|
||||||
bool? isPrepared,
|
bool? isPrepared,
|
||||||
bool? isLoaded,
|
bool? isLoaded,
|
||||||
bool? isUnloaded,
|
bool? isUnloaded,
|
||||||
@@ -265,6 +270,7 @@ class EventEquipment {
|
|||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: equipmentId ?? this.equipmentId,
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
quantity: quantity ?? this.quantity,
|
quantity: quantity ?? this.quantity,
|
||||||
|
rationale: rationale ?? this.rationale,
|
||||||
isPrepared: isPrepared ?? this.isPrepared,
|
isPrepared: isPrepared ?? this.isPrepared,
|
||||||
isLoaded: isLoaded ?? this.isLoaded,
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
isUnloaded: isUnloaded ?? this.isUnloaded,
|
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ class AiAssistantChatTurn {
|
|||||||
const AiAssistantChatTurn({required this.isUser, required this.text});
|
const AiAssistantChatTurn({required this.isUser, required this.text});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
|
||||||
|
class AiEquipmentDocument {
|
||||||
|
final String base64Data;
|
||||||
|
final String mimeType;
|
||||||
|
final String? fileName;
|
||||||
|
|
||||||
|
const AiEquipmentDocument({
|
||||||
|
required this.base64Data,
|
||||||
|
required this.mimeType,
|
||||||
|
this.fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Un item proposé par l'IA dans la liste de matériel.
|
/// Un item proposé par l'IA dans la liste de matériel.
|
||||||
class AiEquipmentProposalItem {
|
class AiEquipmentProposalItem {
|
||||||
final String equipmentId;
|
final String equipmentId;
|
||||||
@@ -48,10 +61,12 @@ class AiEquipmentProposal {
|
|||||||
class AiEquipmentAssistantResponse {
|
class AiEquipmentAssistantResponse {
|
||||||
final String assistantMessage;
|
final String assistantMessage;
|
||||||
final AiEquipmentProposal? proposal;
|
final AiEquipmentProposal? proposal;
|
||||||
|
final List<String> debugLogs;
|
||||||
|
|
||||||
const AiEquipmentAssistantResponse({
|
const AiEquipmentAssistantResponse({
|
||||||
required this.assistantMessage,
|
required this.assistantMessage,
|
||||||
this.proposal,
|
this.proposal,
|
||||||
|
this.debugLogs = const [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +89,7 @@ class AiEquipmentAssistantService {
|
|||||||
String? excludeEventId,
|
String? excludeEventId,
|
||||||
List<EventEquipment> currentAssignedEquipment = const [],
|
List<EventEquipment> currentAssignedEquipment = const [],
|
||||||
List<EventEquipment> workingProposalEquipment = const [],
|
List<EventEquipment> workingProposalEquipment = const [],
|
||||||
|
AiEquipmentDocument? document,
|
||||||
}) async {
|
}) async {
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'startDate': startDate.toIso8601String(),
|
'startDate': startDate.toIso8601String(),
|
||||||
@@ -94,6 +110,14 @@ class AiEquipmentAssistantService {
|
|||||||
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
|
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
|
||||||
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
|
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
|
||||||
|
|
||||||
|
if (document != null) {
|
||||||
|
payload['document'] = {
|
||||||
|
'mimeType': document.mimeType,
|
||||||
|
'data': document.base64Data,
|
||||||
|
if (document.fileName != null) 'fileName': document.fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
|
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
|
||||||
|
|
||||||
@@ -101,6 +125,9 @@ class AiEquipmentAssistantService {
|
|||||||
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
|
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
|
||||||
final proposal = _parseProposal(result['proposal']);
|
final proposal = _parseProposal(result['proposal']);
|
||||||
|
|
||||||
|
final rawLogs = result['debugLogs'];
|
||||||
|
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
|
||||||
|
|
||||||
DebugLog.info(
|
DebugLog.info(
|
||||||
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
|
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
|
||||||
);
|
);
|
||||||
@@ -110,6 +137,7 @@ class AiEquipmentAssistantService {
|
|||||||
? assistantMessage
|
? assistantMessage
|
||||||
: 'Je n\'ai pas pu générer de réponse.',
|
: 'Je n\'ai pas pu générer de réponse.',
|
||||||
proposal: proposal,
|
proposal: proposal,
|
||||||
|
debugLogs: debugLogs,
|
||||||
);
|
);
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
DebugLog.error('[AiEquipmentAssistantService] API error', e);
|
DebugLog.error('[AiEquipmentAssistantService] API error', e);
|
||||||
@@ -148,7 +176,11 @@ class AiEquipmentAssistantService {
|
|||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
rationale: rationale,
|
rationale: rationale,
|
||||||
));
|
));
|
||||||
eventEquipmentList.add(EventEquipment(equipmentId: equipmentId, quantity: quantity));
|
eventEquipmentList.add(EventEquipment(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
quantity: quantity,
|
||||||
|
rationale: rationale,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,11 +163,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _identifierController,
|
controller: _identifierController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Identifiant *',
|
labelText: 'Identifiant (Laissez vide pour auto-génération) *',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.tag),
|
prefixIcon: const Icon(Icons.tag),
|
||||||
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
|
hintText: isEditing ? null : 'Auto-attribué par défaut',
|
||||||
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
|
helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle',
|
||||||
),
|
),
|
||||||
enabled: !isEditing,
|
enabled: !isEditing,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
|
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
/// Résultat retourné par le dialog après confirmation de la proposition IA.
|
/// Résultat retourné par le dialog après confirmation de la proposition IA.
|
||||||
class AiProposalResult {
|
class AiProposalResult {
|
||||||
@@ -47,6 +50,8 @@ class _AiEquipmentAssistantDialogState
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
AiEquipmentProposal? _latestProposal;
|
AiEquipmentProposal? _latestProposal;
|
||||||
late List<EventEquipment> _workingEquipment;
|
late List<EventEquipment> _workingEquipment;
|
||||||
|
AiEquipmentDocument? _selectedDocument;
|
||||||
|
List<String> _sessionLogs = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -90,12 +95,17 @@ class _AiEquipmentAssistantDialogState
|
|||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_messages.add(_AssistantChatMessage.user(userMessage));
|
_messages.add(_AssistantChatMessage.user(userMessage));
|
||||||
|
if (_selectedDocument != null) {
|
||||||
|
_messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]'));
|
||||||
|
}
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final documentToSend = _selectedDocument;
|
||||||
|
_selectedDocument = null; // Clear after sending
|
||||||
final response = await _assistantService
|
final response = await _assistantService
|
||||||
.generateProposal(
|
.generateProposal(
|
||||||
startDate: widget.startDate,
|
startDate: widget.startDate,
|
||||||
@@ -105,6 +115,7 @@ class _AiEquipmentAssistantDialogState
|
|||||||
currentAssignedEquipment: widget.currentAssignedEquipment,
|
currentAssignedEquipment: widget.currentAssignedEquipment,
|
||||||
workingProposalEquipment: _workingEquipment,
|
workingProposalEquipment: _workingEquipment,
|
||||||
userMessage: userMessage,
|
userMessage: userMessage,
|
||||||
|
document: documentToSend,
|
||||||
history: _messages
|
history: _messages
|
||||||
.map((message) => AiAssistantChatTurn(
|
.map((message) => AiAssistantChatTurn(
|
||||||
isUser: message.isUser, text: message.text))
|
isUser: message.isUser, text: message.text))
|
||||||
@@ -124,6 +135,7 @@ class _AiEquipmentAssistantDialogState
|
|||||||
response.proposal!.asEventEquipment,
|
response.proposal!.asEventEquipment,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_sessionLogs.addAll(response.debugLogs);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
@@ -159,6 +171,91 @@ class _AiEquipmentAssistantDialogState
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDocument() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.bytes != null) {
|
||||||
|
final base64String = base64Encode(file.bytes!);
|
||||||
|
String mimeType = 'application/octet-stream';
|
||||||
|
if (file.extension == 'pdf') mimeType = 'application/pdf';
|
||||||
|
else if (file.extension == 'txt') mimeType = 'text/plain';
|
||||||
|
else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg';
|
||||||
|
else if (file.extension == 'png') mimeType = 'image/png';
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedDocument = AiEquipmentDocument(
|
||||||
|
base64Data: base64String,
|
||||||
|
mimeType: mimeType,
|
||||||
|
fileName: file.name,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Erreur lors de la selection du document : $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLogsDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Logs de l\'IA'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _sessionLogs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final log = _sessionLogs[index];
|
||||||
|
final isError = log.startsWith('[ERROR]');
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text(
|
||||||
|
log,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: isError ? Colors.red : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final fullLogs = _sessionLogs.join('\n');
|
||||||
|
Clipboard.setData(ClipboardData(text: fullLogs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Logs copiés dans le presse-papiers')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Copier tout'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Dialog(
|
return Dialog(
|
||||||
@@ -172,6 +269,12 @@ class _AiEquipmentAssistantDialogState
|
|||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
title: const Text('Assistant IA Logisticien'),
|
title: const Text('Assistant IA Logisticien'),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (_sessionLogs.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bug_report),
|
||||||
|
tooltip: 'Voir les logs',
|
||||||
|
onPressed: _showLogsDialog,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed:
|
onPressed:
|
||||||
@@ -208,8 +311,12 @@ class _AiEquipmentAssistantDialogState
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
),
|
),
|
||||||
SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
Text(
|
Expanded(
|
||||||
'Generation en cours... verification du materiel et disponibilites.'),
|
child: const Text(
|
||||||
|
'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)',
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -230,10 +337,42 @@ class _AiEquipmentAssistantDialogState
|
|||||||
),
|
),
|
||||||
if (_latestProposal != null)
|
if (_latestProposal != null)
|
||||||
_buildProposalSummary(_latestProposal!),
|
_buildProposalSummary(_latestProposal!),
|
||||||
|
if (_selectedDocument != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 16, top: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.attach_file, color: Colors.blue, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_selectedDocument!.fileName ?? 'Document joint',
|
||||||
|
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedDocument = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Retirer le document',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.attach_file),
|
||||||
|
onPressed: _isLoading ? null : _pickDocument,
|
||||||
|
tooltip: 'Joindre un devis ou document',
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageController,
|
controller: _messageController,
|
||||||
@@ -274,17 +413,140 @@ class _AiEquipmentAssistantDialogState
|
|||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bubbleColor,
|
color: bubbleColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(16),
|
||||||
|
topRight: const Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
|
||||||
|
bottomRight: Radius.circular(message.isUser ? 4 : 16),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
border:
|
border:
|
||||||
message.isUser ? null : Border.all(color: Colors.grey.shade300),
|
message.isUser ? null : Border.all(color: Colors.grey.shade200),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: message.isUser
|
||||||
message.text,
|
? Text(message.text, style: TextStyle(color: textColor))
|
||||||
style: TextStyle(color: textColor),
|
: _buildAssistantMessageContent(message.text),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAssistantMessageContent(String text) {
|
||||||
|
// Si le message semble structuré par l'IA avec nos nouvelles règles
|
||||||
|
if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) {
|
||||||
|
final sections = text.split('\n\n');
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: sections.map((section) {
|
||||||
|
final isAdded = section.contains('Matériel ajouté :');
|
||||||
|
final isMissing = section.contains('Matériel non trouvé');
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
return _buildStatusSection(
|
||||||
|
title: section.split('\n').first,
|
||||||
|
content: section.split('\n').skip(1).join('\n'),
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
bgColor: Colors.green.shade50,
|
||||||
|
);
|
||||||
|
} else if (isMissing) {
|
||||||
|
return _buildStatusSection(
|
||||||
|
title: section.split('\n').first,
|
||||||
|
content: section.split('\n').skip(1).join('\n'),
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
bgColor: Colors.orange.shade50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Text(section),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusSection({
|
||||||
|
required String title,
|
||||||
|
required String content,
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
required Color bgColor,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title.replaceAll(':', '').trim(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (content.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
content.trim(),
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade800),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmProposal({bool excludeAlternatives = false}) {
|
||||||
|
if (_latestProposal == null) return;
|
||||||
|
|
||||||
|
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
|
||||||
|
List<String> containerIds = List.from(_latestProposal!.containerIds);
|
||||||
|
|
||||||
|
if (excludeAlternatives) {
|
||||||
|
// On utilise la liste des items d'origine pour savoir lesquels exclure
|
||||||
|
// car ils contiennent le champ rationale (avant conversion en EventEquipment)
|
||||||
|
final idsToExclude = _latestProposal!.items
|
||||||
|
.where((item) {
|
||||||
|
final rationale = item.rationale.toLowerCase();
|
||||||
|
return rationale.contains('alternative') ||
|
||||||
|
rationale.contains('remplacement') ||
|
||||||
|
rationale.contains('indisponible');
|
||||||
|
})
|
||||||
|
.map((item) => item.equipmentId)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
AiProposalResult(
|
||||||
|
equipment: equipment,
|
||||||
|
containerIds: containerIds,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,21 +555,38 @@ class _AiEquipmentAssistantDialogState
|
|||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(16),
|
||||||
constraints: const BoxConstraints(maxHeight: 240),
|
constraints: const BoxConstraints(maxHeight: 280),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.green.shade50,
|
color: Colors.indigo.shade50,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.green.shade200),
|
border: Border.all(color: Colors.indigo.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
|
||||||
|
const SizedBox(width: 12),
|
||||||
const Text(
|
const Text(
|
||||||
'Recapitulatif propose',
|
'Récapitulatif de la proposition IA',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.indigo,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
controller: _proposalScrollController,
|
controller: _proposalScrollController,
|
||||||
@@ -319,28 +598,58 @@ class _AiEquipmentAssistantDialogState
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(proposal.summary),
|
Text(
|
||||||
|
proposal.summary,
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
if (proposal.items.isNotEmpty) ...[
|
if (proposal.items.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Matériel individuel :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
...proposal.items.map((item) {
|
...proposal.items.map((item) {
|
||||||
|
final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement');
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isAlt ? Icons.swap_horiz : Icons.add_circle_outline,
|
||||||
|
size: 14,
|
||||||
|
color: isAlt ? Colors.orange : Colors.indigo,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'- ${item.equipmentId} x${item.quantity} - ${item.rationale}',
|
'${item.equipmentId} x${item.quantity}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
if (proposal.containerIds.isNotEmpty) ...[
|
if (proposal.containerIds.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
const Text(
|
||||||
'Boites proposees :',
|
'Fly-cases & Boîtes :',
|
||||||
style: TextStyle(fontWeight: FontWeight.w600),
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
...proposal.containerIds.map((id) {
|
...proposal.containerIds.map((id) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||||
child: Text('- $id'),
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.inventory_2_outlined, size: 14, color: Colors.indigo),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(id, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -349,18 +658,30 @@ class _AiEquipmentAssistantDialogState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: _isLoading
|
onPressed: _isLoading ? null : () => _confirmProposal(),
|
||||||
? null
|
icon: const Icon(Icons.check),
|
||||||
: () => Navigator.of(context).pop(
|
label: const Text('Tout ajouter'),
|
||||||
AiProposalResult(
|
style: ElevatedButton.styleFrom(
|
||||||
equipment: proposal.asEventEquipment,
|
backgroundColor: Colors.indigo,
|
||||||
containerIds: proposal.containerIds,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.add_task),
|
OutlinedButton.icon(
|
||||||
label: const Text('Confirmer et Ajouter'),
|
onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true),
|
||||||
|
icon: const Icon(Icons.filter_list_off),
|
||||||
|
label: const Text('Ajouter sans alternatives'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.indigo,
|
||||||
|
side: const BorderSide(color: Colors.indigo),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ class _EventAssignedEquipmentSectionState
|
|||||||
_applyAiProposal(result);
|
_applyAiProposal(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyAiProposal(AiProposalResult result) {
|
void _applyAiProposal(AiProposalResult result) async {
|
||||||
final existingById = {
|
final existingById = {
|
||||||
for (final equipment in widget.assignedEquipment)
|
for (final equipment in widget.assignedEquipment)
|
||||||
equipment.equipmentId: equipment,
|
equipment.equipmentId: equipment,
|
||||||
@@ -268,9 +268,30 @@ class _EventAssignedEquipmentSectionState
|
|||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
return proposed;
|
return proposed;
|
||||||
}
|
}
|
||||||
return existing.copyWith(quantity: proposed.quantity);
|
return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
// 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants
|
||||||
|
if (result.containerIds.isNotEmpty) {
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
final containers = await containerProvider.getContainersByIds(result.containerIds);
|
||||||
|
|
||||||
|
for (var container in containers) {
|
||||||
|
for (var childEquipmentId in container.equipmentIds) {
|
||||||
|
// Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition)
|
||||||
|
final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||||
|
if (!exists) {
|
||||||
|
updatedEquipment.add(EventEquipment(
|
||||||
|
equipmentId: childEquipmentId,
|
||||||
|
quantity: 1,
|
||||||
|
rationale: 'Inclus dans ${container.id}',
|
||||||
|
));
|
||||||
|
DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final updatedContainers = [...widget.assignedContainers];
|
final updatedContainers = [...widget.assignedContainers];
|
||||||
for (final containerId in result.containerIds) {
|
for (final containerId in result.containerIds) {
|
||||||
if (!updatedContainers.contains(containerId)) {
|
if (!updatedContainers.contains(containerId)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user