From e8cc89cb2f735c503b9d1ed0a263f220c7a624eb Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 21 Feb 2026 22:48:16 +0100 Subject: [PATCH] feat(ai): synchronize batch and individual analysis payloads and models, optimize concurrency --- src/app/api/ai/batch-analyze/route.ts | 23 +++--- .../components/ai-insight-block.tsx | 1 + .../grid/components/bulk-ai-analyzer.tsx | 76 ++++++++----------- 3 files changed, 42 insertions(+), 58 deletions(-) diff --git a/src/app/api/ai/batch-analyze/route.ts b/src/app/api/ai/batch-analyze/route.ts index 7907dbc..0cfe499 100644 --- a/src/app/api/ai/batch-analyze/route.ts +++ b/src/app/api/ai/batch-analyze/route.ts @@ -12,8 +12,8 @@ const BatchAnalyzeSchema = z.object({ ventes: z.number().nullable().optional(), marge: z.number().nullable().optional(), score: z.number().nullable().optional(), - gammeInit: z.string().nullable().optional(), - historique: z.string().nullable().optional(), + codeGamme: z.string().nullable().optional(), + sales12m: z.record(z.string(), z.number()).nullable().optional(), nomenclature: z.string().nullable().optional(), })), }); @@ -22,7 +22,7 @@ export async function POST(req: NextRequest) { // Read the API key from DB config (same pattern as /api/ai/analyze) const config = await getSavedDatabaseConfig(); const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; - const model = config?.openRouterModel || "meta-llama/llama-3.3-70b-instruct:free"; + const model = config?.openRouterModel || "google/gemini-flash-1.5"; if (!apiKey) { console.error("[batch-analyze] API key manquante."); @@ -39,20 +39,17 @@ export async function POST(req: NextRequest) { const { rayon, products } = parsed.data; - // Optimized System Prompt: Merged from analyze/route.ts as requested by user + // Optimized System Prompt: Perfectly aligned with analyze/route.ts const systemPrompt = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel. -En analysant les données de ventes fournies (CA, Marge, Volume, Historique) et surtout le SCORE de performance relative (0-100), génère pour chaque produit une recommandation de gamme (A=Permanent, C=Saisonnier, Z=Sortie). +En analysant les données de ventes fournies (CA, Marge, Volume, Historique mensuel) et surtout le SCORE de performance relative (0-100), génère pour chaque produit une recommandation de gamme (A=Permanent, C=Saisonnier, Z=Sortie). CRITÈRES PRIORITAIRES : -- SCORE < 20 : Recommandation "Z" (Sortie) quasi-obligatoire. Même si le volume semble correct, le produit est un boulet par rapport au reste du fournisseur. -- A (Permanent) : Produit avec une rotation régulière ET un score satisfaisant (> 30-40). -- C (Saisonnier) : Pics de ventes concentrés sur l'historique. Aide-toi de la nomenclature. +- SCORE < 20 : Recommandation "Z" (Sortie) quasi-obligatoire. Un score faible signifie que le produit est un fardeau par rapport aux autres produits du fournisseur. +- A (Permanent) : Produit avec une rotation régulière ET un score satisfaisant (> 35). +- C (Saisonnier) : Pics de ventes concentrés sur l'historique (sales12m). Aide-toi de la nomenclature. - Z (Sortie) : Ventes nulles, rotation insuffisante ou score médiocre. -DÉTECTION : -Marque 'isDuplicate: true' si le produit semble être un doublon dans le lot. - IMPORTANT : RÉPONDS UNIQUEMENT EN JSON VALIDE. Format: { @@ -66,9 +63,7 @@ Format: ] } -INTERDICTION FORMELLE : N'utilise jamais la phrase "Justification courte basée sur les données" comme réponse. Tu dois rédiger une analyse réelle. - -Données : codein, nom, ca (€), ventes (unités), marge (%), gammeInit, historique, nomenclature.`; +Données fournies : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), codeGamme (actuel), sales12m (historique par mois), nomenclature.`; const userPrompt = `Analyse cette liste de produits: \n${JSON.stringify(products, null, 2)} `; diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx index e433edb..cc88028 100644 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ b/src/features/ai-copilot/components/ai-insight-block.tsx @@ -24,6 +24,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { totalQuantite: row.totalQuantite, sales12m: row.sales12m, codeGamme: row.codeGamme, + score: row.score, }); }; diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index ef79600..088af23 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -17,6 +17,7 @@ export function BulkAiAnalyzer() { console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows"); setIsAnalyzing(true); + let completed = 0; try { // Group by Nomenclature (libelle3 or a fallback) @@ -27,39 +28,34 @@ export function BulkAiAnalyzer() { groups[rayon].push(r); }); - // Create chunks of up to 50 products per rayon const CHUNK_SIZE = 50; const chunks: { rayon: string; items: ProductRow[] }[] = []; for (const [rayon, items] of Object.entries(groups)) { for (let i = 0; i < items.length; i += CHUNK_SIZE) { - chunks.push({ - rayon, - items: items.slice(i, i + CHUNK_SIZE) - }); + chunks.push({ rayon, items: items.slice(i, i + CHUNK_SIZE) }); } } setProgress({ current: 0, total: chunks.length, message: "Initialisation...", errors: 0 }); - // Process sequentially - let completed = 0; - for (const chunk of chunks) { - setProgress(prev => ({ ...prev, current: completed, total: chunks.length, message: `Analyse du rayon: ${chunk.rayon}` })); + // Process with a continuous concurrency limit + const CONCURRENCY = 3; + const remainingChunks = [...chunks]; + + const processNext = async () => { + const chunk = remainingChunks.shift(); + if (!chunk) return; - // Enrich payload with more context to improve AI decision quality const payloadProducts = chunk.items.map(r => ({ codein: r.codein, nom: r.libelle1, ca: r.totalCa || 0, ventes: r.totalQuantite || 0, marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0, - gammeInit: r.codeGammeInit || "N/A", - // Detailed history: "YYYYMM: Qty, YYYYMM: Qty..." to help AI see seasonality - historique: Object.entries(r.sales12m || {}) - .map(([month, qty]) => `${month}:${qty}`) - .join(", "), - // Contextual nomenclature + score: r.score || 0, + codeGamme: r.codeGamme || "N/A", + sales12m: r.sales12m || {}, // Send raw object for better AI analysis nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}` })); @@ -74,66 +70,58 @@ export function BulkAiAnalyzer() { body: JSON.stringify({ rayon: chunk.rayon, products: payloadProducts }) }); - if (res.status === 429) { + if (res.status === 429 && attempt < MAX_CHUNK_RETRIES) { const errData = await res.json().catch(() => ({})); const waitSecs = errData.retryAfter ?? (30 * attempt); - console.warn(`[BulkAiAnalyzer] Rate limited. Waiting ${waitSecs}s before retry ${attempt}/${MAX_CHUNK_RETRIES}`); - for (let t = waitSecs; t > 0; t--) { - setProgress(prev => ({ ...prev, message: `⏳ Lot ${completed + 1}/${chunks.length} — Rate limit, reprise dans ${t}s...` })); + setProgress(prev => ({ ...prev, message: `⏳ Lot ${completed + 1}/${chunks.length} — Limite API, reprise dans ${t}s...` })); await new Promise(r => setTimeout(r, 1000)); } - continue; // retry + continue; } - break; // success or non-retryable error + break; } if (!res || !res.ok) { const errText = await res?.text().catch(() => ""); - console.error(`[BulkAiAnalyzer] Erreur HTTP ${res?.status} sur le lot ${chunk.rayon}:`, errText); + console.error(`[BulkAiAnalyzer] Erreur HTTP sur le lot ${chunk.rayon}:`, errText); setProgress(prev => ({ ...prev, errors: prev.errors + 1 })); } else { const data = await res.json(); - console.log(`[BulkAiAnalyzer] Response for ${chunk.rayon}:`, data); - if (data && Array.isArray(data.results)) { - let applied = 0; data.results.forEach((reco: any) => { if (reco.codein && reco.recommandationGamme) { setDraftGamme(reco.codein, reco.recommandationGamme); - const baseJustification = reco.justificationCourte || "Aucune explication."; - const justification = `Gamme ${reco.recommandationGamme} — ${baseJustification}`; + const justification = `Gamme ${reco.recommandationGamme} — ${reco.justificationCourte || "Analyse effectuée."}`; setInsight(reco.codein, justification, reco.isDuplicate ?? false); - applied++; } }); - console.log(`[BulkAiAnalyzer] Applied ${applied} recommendations for ${chunk.rayon}`); - } else { - console.warn(`[BulkAiAnalyzer] Unexpected response shape for ${chunk.rayon}:`, data); } } } catch (e) { console.error(`[BulkAiAnalyzer] Failed fetch for chunk ${chunk.rayon}:`, e); setProgress(prev => ({ ...prev, errors: prev.errors + 1 })); + } finally { + completed++; + setProgress(prev => ({ + ...prev, + current: completed, + message: completed === chunks.length ? "Analyse terminée" : `Analyse: ${completed}/${chunks.length} lots` + })); + // Process next chunk in the same worker + await processNext(); } + }; - completed++; - - // Always wait 20s between chunks — free tier allows ~3 req/min - if (completed < chunks.length) { - setProgress(prev => ({ ...prev, current: completed, message: `En attente... (lot ${completed}/${chunks.length})` })); - await new Promise(resolve => setTimeout(resolve, 20000)); - } - } + // Start workers + const workers = Array.from({ length: Math.min(CONCURRENCY, chunks.length) }, () => processNext()); + await Promise.all(workers); setProgress(prev => ({ ...prev, current: completed, total: chunks.length, message: `Analyse terminée ! (${prev.errors > 0 ? prev.errors + " erreurs" : "succès"})` })); - setTimeout(() => { - setIsAnalyzing(false); - }, 3000); + setTimeout(() => setIsAnalyzing(false), 3000); } catch (error) { console.error("Erreur globale lors de l'analyse:", error); - alert("Une erreur inattendue s'est produite pendant l'analyse globale."); setIsAnalyzing(false); } };