From ce0085a5c7e4eed528cf6dd17d57cff1c141ad18 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 09:17:52 +0100 Subject: [PATCH] feat: Implement bulk AI product categorization through a new UI component and API route. --- src/app/api/ai/analyze/route.ts | 103 ++++++++-- .../grid/components/bulk-ai-analyzer.tsx | 188 +++++++----------- 2 files changed, 161 insertions(+), 130 deletions(-) diff --git a/src/app/api/ai/analyze/route.ts b/src/app/api/ai/analyze/route.ts index 6f4c472..53d784b 100644 --- a/src/app/api/ai/analyze/route.ts +++ b/src/app/api/ai/analyze/route.ts @@ -11,10 +11,20 @@ const AnalyzeSchema = z.object({ sales12m: z.record(z.string(), z.number()), codeGamme: z.string().nullable(), score: z.number().nullable().optional(), + // Champs enrichis (optionnels, envoyés par le batch) + weightInNomenclature2: z.number().optional(), + adjustedCaWeight: z.number().optional(), + nomenclature2Weight: z.number().optional(), + scorePercentile: z.number().optional(), + moisActifs: z.number().optional(), + nomenclature: z.string().optional(), + // Mode batch : retourne du JSON structuré au lieu de texte libre + batchMode: z.boolean().optional(), }); const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; -const SYSTEM_PROMPT = `Tu es un expert en stratégie d'achat retail. Ton rôle est de catégoriser ce produit (A=Permanent, C=Saisonnier, Z=Sortie) via une analyse de VALEUR CONTEXTUELLE. + +const SYSTEM_PROMPT_INDIVIDUAL = `Tu es un expert en stratégie d'achat retail. Ton rôle est de catégoriser ce produit (A=Permanent, C=Saisonnier, Z=Sortie) via une analyse de VALEUR CONTEXTUELLE. L'IMPORTANCE ÉCONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE. @@ -31,39 +41,78 @@ DÉFINITION : Réponse courte, mentionnant la contribution économique et le score. Ne mentionne aucun seuil en euros. Format: "[Recommandation]: [Justification basée sur la valeur et le score]"`; +const SYSTEM_PROMPT_BATCH = `Tu es un expert en assortiment retail. Categorise ce produit : A (garder en permanence), C (saisonnier), Z (sortir de la gamme). + +L'IMPORTANCE ECONOMIQUE PRIME SUR LE SCORE. + +DONNEES CLES : +- weightInNomenclature2 : % du CA du produit dans sa categorie. LE CRITERE PRINCIPAL. >= 5% = pilier de categorie → A. +- adjustedCaWeight : % du CA produit dans le total fournisseur (extrapole reseau). +- scorePercentile : rang du produit parmi le fournisseur (50 = median). Utilise CA POUR COMPARER, pas le score brut. +- moisActifs : mois avec ventes > 0 sur 12 mois. >= 8 = rotation reguliere → A. <= 3 = sporadique. + +REGLES : +1. weightInNomenclature2 >= 5% → A (pilier de categorie) +2. moisActifs >= 8 → A (fond de rayon, rotation reguliere) +3. scorePercentile >= 50 ET moisActifs >= 4 → A (performeur regulier) +4. moisActifs 2-4 avec pics concentres → C (saisonnier) +5. Z seulement si TOUT est faible : weightInNomenclature2 bas + moisActifs < 5 + scorePercentile < 30 + +Reponds UNIQUEMENT en JSON : {"recommandationGamme":"A|C|Z","justificationCourte":"..."}`; + export async function POST(req: NextRequest) { - console.log("[AI] Analysis request received."); const config = await getSavedDatabaseConfig(); const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; const model = config?.openRouterModel || "google/gemini-flash-1.5"; if (!apiKey) { - console.error("[AI] OPENROUTER_API_KEY is missing (neither in env nor in config file)."); + console.error("[AI] OPENROUTER_API_KEY is missing."); return NextResponse.json({ error: "OPENROUTER_API_KEY not configured. Please set it in Settings." }, { status: 503 }); } - console.log(`[AI] Using model: ${model} with API key starting with ${apiKey.substring(0, 10)}...`); - const body = await req.json(); const parsed = AnalyzeSchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 }); } - const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme, score } = parsed.data; + const p = parsed.data; + const isBatch = p.batchMode === true; // Format monthly sales for the prompt - const monthlySummary = Object.entries(sales12m) + const monthlySummary = Object.entries(p.sales12m) .map(([k, v]) => `${k}: ${Math.round(v)} unités`) .join(", "); - const userMessage = `Produit: "${libelle1}" (Code: ${codein}) -Gamme actuelle: ${codeGamme ?? "Non définie"} -Score de performance: ${score ?? "N/A"}/100 -CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités + let userMessage: string; + let systemPrompt: string; + + if (isBatch) { + // Mode batch : prompt simplifié avec métriques enrichies, PAS de codeGamme + systemPrompt = SYSTEM_PROMPT_BATCH; + const enrichedLines = [ + `Produit: "${p.libelle1}" (${p.codein})`, + p.nomenclature ? `Nomenclature: ${p.nomenclature}` : null, + `CA: ${p.totalCa.toFixed(0)} EUR | Marge: ${p.tauxMarge.toFixed(1)}% | Volume: ${Math.round(p.totalQuantite)} unites`, + p.weightInNomenclature2 != null ? `Poids dans sa categorie N2: ${p.weightInNomenclature2}%` : null, + p.adjustedCaWeight != null ? `Poids global fournisseur (ajuste): ${p.adjustedCaWeight}%` : null, + p.nomenclature2Weight != null ? `Poids de la categorie N2 dans le fournisseur: ${p.nomenclature2Weight}%` : null, + p.scorePercentile != null ? `Rang percentile: ${p.scorePercentile}/100` : null, + p.moisActifs != null ? `Mois actifs (ventes > 0): ${p.moisActifs}/12` : null, + `Historique: ${monthlySummary}`, + ].filter(Boolean).join("\n"); + userMessage = enrichedLines; + } else { + // Mode individuel : prompt original + systemPrompt = SYSTEM_PROMPT_INDIVIDUAL; + userMessage = `Produit: "${p.libelle1}" (Code: ${p.codein}) +Gamme actuelle: ${p.codeGamme ?? "Non définie"} +Score de performance: ${p.score ?? "N/A"}/100 +CA total 12m: ${p.totalCa.toFixed(0)}€ | Taux de marge: ${p.tauxMarge.toFixed(1)}% | Volume: ${Math.round(p.totalQuantite)} unités Historique mensuel: ${monthlySummary} Quelle gamme recommandes-tu et pourquoi ?`; + } try { const response = await fetch(OPENROUTER_URL, { @@ -77,23 +126,47 @@ Quelle gamme recommandes-tu et pourquoi ?`; body: JSON.stringify({ model, messages: [ - { role: "system", content: SYSTEM_PROMPT }, + { role: "system", content: systemPrompt }, { role: "user", content: userMessage }, ], + ...(isBatch ? { response_format: { type: "json_object" } } : {}), max_tokens: 150, - temperature: 0.3, + temperature: 0.1, }), }); + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests"); + const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; + return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 }); + } + if (!response.ok) { const err = await response.text(); return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status }); } const data = await response.json(); - const insight = data.choices?.[0]?.message?.content ?? "Analyse indisponible."; + const content: string = data.choices?.[0]?.message?.content ?? ""; - return NextResponse.json({ insight, codein }); + if (isBatch) { + // Parse JSON response for batch mode + let jsonStr = content; + const start = content.indexOf("{"); + const end = content.lastIndexOf("}"); + if (start !== -1 && end !== -1) { + jsonStr = content.slice(start, end + 1); + } + const result = JSON.parse(jsonStr); + return NextResponse.json({ + codein: p.codein, + recommandationGamme: result.recommandationGamme || "A", + justificationCourte: result.justificationCourte || "", + isDuplicate: false, + }); + } else { + return NextResponse.json({ insight: content, codein: p.codein }); + } } catch (err) { const msg = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json({ error: msg }, { status: 500 }); diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 6c23809..2f87266 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -4,7 +4,6 @@ import React, { useState } from "react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store"; import { Sparkles, Loader2, CheckCircle2 } from "lucide-react"; -import { ProductRow } from "@/types/grid"; export function BulkAiAnalyzer() { const [isAnalyzing, setIsAnalyzing] = useState(false); @@ -15,29 +14,16 @@ export function BulkAiAnalyzer() { const handleAnalyze = async () => { const { rows } = useGridStore.getState(); const supplierTotalCa = rows.reduce((acc, row) => acc + (row.totalCa || 0), 0); - const supplierTotalMarge = rows.reduce((acc, row) => acc + (row.totalMarge || 0), 0); - console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows", { supplierTotalCa, supplierTotalMarge }); + console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows"); // --- Statistiques globales pour contextualiser le score --- const allScores = rows.map(r => r.score || 0).sort((a, b) => a - b); const totalProducts = rows.length; - const medianScore = totalProducts > 0 - ? (totalProducts % 2 === 0 - ? (allScores[totalProducts / 2 - 1] + allScores[totalProducts / 2]) / 2 - : allScores[Math.floor(totalProducts / 2)]) - : 0; - - const scoreDistribution = { - above70: allScores.filter(s => s > 70).length, - between30and70: allScores.filter(s => s >= 30 && s <= 70).length, - below30: allScores.filter(s => s < 30).length, - }; - const maxStoreCount = Math.max(...rows.map(r => r.workingStores?.length || 1), 1); - // Percentile par produit (rang centile 0-100) + // Percentile par produit const scorePercentileMap = new Map(); rows.forEach(r => { const productScore = r.score || 0; @@ -45,7 +31,7 @@ export function BulkAiAnalyzer() { scorePercentileMap.set(r.codein, Math.round((rank / totalProducts) * 100)); }); - // --- Double poids par nomenclature niveau 2 --- + // Double poids par nomenclature niveau 2 const nomenclature2CaMap = new Map(); rows.forEach(r => { const n2 = r.libelleNiveau2 || "Non classe"; @@ -59,99 +45,69 @@ export function BulkAiAnalyzer() { : 0); }); - const nomenclature2Count = nomenclature2CaMap.size; + // Préparer les payloads enrichis pour chaque produit + const productPayloads = rows.map(r => { + const rawCaWeight = supplierTotalCa > 0 + ? parseFloat(((r.totalCa / supplierTotalCa) * 100).toFixed(3)) + : 0; + const storeCount = r.workingStores?.length || 1; + const storeRatio = storeCount < maxStoreCount ? maxStoreCount / storeCount : 1; + const adjustedCaWeight = parseFloat((rawCaWeight * storeRatio).toFixed(3)); + const n2 = r.libelleNiveau2 || "Non classe"; + const n2Ca = nomenclature2CaMap.get(n2) || 1; + const weightInNomenclature2 = parseFloat((((r.totalCa || 0) / n2Ca) * 100).toFixed(2)); + const moisActifs = Object.values(r.sales12m || {}).filter(v => v > 0).length; - console.log("[BulkAiAnalyzer] Stats:", { totalProducts, medianScore, scoreDistribution, nomenclature2Count, maxStoreCount }); + return { + codein: r.codein, + libelle1: r.libelle1 || "", + totalCa: r.totalCa || 0, + tauxMarge: r.tauxMarge || 0, + totalQuantite: r.totalQuantite || 0, + sales12m: r.sales12m || {}, + codeGamme: r.codeGamme || null, + score: r.score || 0, + // Champs enrichis + weightInNomenclature2, + adjustedCaWeight, + nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0, + scorePercentile: scorePercentileMap.get(r.codein) ?? 0, + moisActifs, + nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`, + batchMode: true, + }; + }); setIsAnalyzing(true); let completed = 0; + let errors = 0; + + setProgress({ current: 0, total: productPayloads.length, message: "Initialisation...", errors: 0 }); try { - // Group by Nomenclature (libelle3 or a fallback) - const groups: Record = {}; - rows.forEach(r => { - const rayon = r.libelle3 || "Non classifié"; - if (!groups[rayon]) groups[rayon] = []; - groups[rayon].push(r); - }); - - 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) }); - } - } - - setProgress({ current: 0, total: chunks.length, message: "Initialisation...", errors: 0 }); - - // Process with a continuous concurrency limit - const CONCURRENCY = 3; - const remainingChunks = [...chunks]; + const CONCURRENCY = 5; + const remaining = [...productPayloads]; const processNext = async () => { - const chunk = remainingChunks.shift(); - if (!chunk) return; - - const payloadProducts = chunk.items.map(r => { - const rawCaWeight = supplierTotalCa > 0 - ? parseFloat(((r.totalCa / supplierTotalCa) * 100).toFixed(3)) - : 0; - const storeCount = r.workingStores?.length || 1; - const storeRatio = storeCount < maxStoreCount ? maxStoreCount / storeCount : 1; - const adjustedCaWeight = parseFloat((rawCaWeight * storeRatio).toFixed(3)); - const n2 = r.libelleNiveau2 || "Non classe"; - const n2Ca = nomenclature2CaMap.get(n2) || 1; - const weightInNomenclature2 = parseFloat((((r.totalCa || 0) / n2Ca) * 100).toFixed(2)); - - // Compter les mois actifs (ventes > 0) pré-calculé - const moisActifs = Object.values(r.sales12m || {}).filter(v => v > 0).length; - - return { - codein: r.codein, - nom: r.libelle1, - ca: r.totalCa || 0, - adjustedCaWeight, - weightInNomenclature2, - nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0, - ventes: r.totalQuantite || 0, - marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0, - scorePercentile: scorePercentileMap.get(r.codein) ?? 0, - moisActifs, - storeCount, - nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`, - }; - }); + const payload = remaining.shift(); + if (!payload) return; try { let res: Response | null = null; - const MAX_CHUNK_RETRIES = 3; + const MAX_RETRIES = 3; - for (let attempt = 1; attempt <= MAX_CHUNK_RETRIES; attempt++) { - res = await fetch("/api/ai/batch-analyze", { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + res = await fetch("/api/ai/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - rayon: chunk.rayon, - products: payloadProducts, - supplierTotalCa, - supplierTotalMarge, - supplierStats: { - totalProducts, - medianScore: Math.round(medianScore * 10) / 10, - scoreDistribution, - maxStoreCount, - nomenclature2Count, - } - }) + body: JSON.stringify(payload) }); - if (res.status === 429 && attempt < MAX_CHUNK_RETRIES) { + if (res.status === 429 && attempt < MAX_RETRIES) { const errData = await res.json().catch(() => ({})); - const waitSecs = errData.retryAfter ?? (30 * attempt); + const waitSecs = errData.retryAfter ?? (15 * attempt); for (let t = waitSecs; t > 0; t--) { - setProgress(prev => ({ ...prev, message: `⏳ Lot ${completed + 1}/${chunks.length} — Limite API, reprise dans ${t}s...` })); + setProgress(prev => ({ ...prev, message: `⏳ ${completed}/${productPayloads.length} — Limite API, reprise dans ${t}s...` })); await new Promise(r => setTimeout(r, 1000)); } continue; @@ -160,41 +116,43 @@ export function BulkAiAnalyzer() { } if (!res || !res.ok) { - const errText = await res?.text().catch(() => ""); - console.error(`[BulkAiAnalyzer] Erreur HTTP sur le lot ${chunk.rayon}:`, errText); - setProgress(prev => ({ ...prev, errors: prev.errors + 1 })); + errors++; } else { const data = await res.json(); - if (data && Array.isArray(data.results)) { - data.results.forEach((reco: any) => { - if (reco.codein && reco.recommandationGamme) { - setDraftGamme(reco.codein, reco.recommandationGamme); - const justification = `Gamme ${reco.recommandationGamme} — ${reco.justificationCourte || "Analyse effectuée."}`; - setInsight(reco.codein, justification, reco.isDuplicate ?? false); - } - }); + if (data.recommandationGamme) { + setDraftGamme(data.codein, data.recommandationGamme); + const justification = `Gamme ${data.recommandationGamme} — ${data.justificationCourte || "Analyse effectuée."}`; + setInsight(data.codein, justification, data.isDuplicate ?? false); } } - } catch (e) { - console.error(`[BulkAiAnalyzer] Failed fetch for chunk ${chunk.rayon}:`, e); - setProgress(prev => ({ ...prev, errors: prev.errors + 1 })); + } catch { + errors++; } finally { completed++; - setProgress(prev => ({ - ...prev, + setProgress({ current: completed, - message: completed === chunks.length ? "Analyse terminée" : `Analyse: ${completed}/${chunks.length} lots` - })); - // Process next chunk in the same worker + total: productPayloads.length, + message: completed === productPayloads.length + ? "Analyse terminée" + : `Analyse: ${completed}/${productPayloads.length} produits`, + errors, + }); await processNext(); } }; - // Start workers - const workers = Array.from({ length: Math.min(CONCURRENCY, chunks.length) }, () => processNext()); + const workers = Array.from( + { length: Math.min(CONCURRENCY, productPayloads.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"})` })); + setProgress(prev => ({ + ...prev, + current: completed, + total: productPayloads.length, + message: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`, + })); setTimeout(() => setIsAnalyzing(false), 3000); } catch (error) { @@ -227,7 +185,7 @@ export function BulkAiAnalyzer() {
- Analyse terminée ({progress.total} lots analysés) + Analyse terminée ({progress.total} produits analysés)
);