From a07bad60b2c0afab7ac40e2c2bd69a09ed6e667c Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 21 Feb 2026 20:27:08 +0100 Subject: [PATCH] feat(ai): integrate Llama 3.3 batch analysis for products grouped by nomenclature --- src/app/api/ai/batch-analyze/route.ts | 98 ++++++++++++ .../grid/components/bulk-ai-analyzer.tsx | 142 ++++++++++++++++++ .../grid/components/floating-summary-bar.tsx | 4 + 3 files changed, 244 insertions(+) create mode 100644 src/app/api/ai/batch-analyze/route.ts create mode 100644 src/features/grid/components/bulk-ai-analyzer.tsx diff --git a/src/app/api/ai/batch-analyze/route.ts b/src/app/api/ai/batch-analyze/route.ts new file mode 100644 index 0000000..b9f5c56 --- /dev/null +++ b/src/app/api/ai/batch-analyze/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; + +// Schema for the incoming request +const BatchAnalyzeSchema = z.object({ + rayon: z.string(), // Context for the LLM + products: z.array(z.object({ + codein: z.string(), + gtin: z.string().nullable().optional(), + nom: z.string().nullable().optional(), + ventes: z.number().nullable().optional(), + marge: z.number().nullable().optional(), + })), +}); + +export async function POST(req: NextRequest) { + if (!OPENROUTER_API_KEY) { + return NextResponse.json({ error: "Clé API OpenRouter manquante." }, { status: 500 }); + } + + try { + const body = await req.json(); + const parsed = BatchAnalyzeSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Format de données invalide." }, { status: 400 }); + } + + const { rayon, products } = parsed.data; + + // Prompt definition + const systemPrompt = `Tu es un expert en Retail et en gestion d'assortiment. +Ta mission est d'analyser un lot de produits appartenant au rayon "${rayon}". +Pour chaque produit, tu dois recommander une Gamme (A, B, C ou Z) basée sur ses performances de ventes (volume) et sa marge (%). +Tu dois aussi détecter les doublons évidents (même produit, GTIN similaire, ventes réparties) en mettant isDuplicate: true le cas échéant. + +Règles de Gamme : +- A : Produit phare, rotation forte, excellente marge. +- B : Produit cœur de gamme, rotation moyenne. +- C : Dépannage ou niche, faible rotation mais potentiellement bonne marge. +- Z : Produit à déréférencer ou mort (ventes très faibles, marge mauvaise). + +TU DOIS REPONDRE UNIQUEMENT EN FORMAT JSON VALIDE. AUCUN TEXTE AVANT OU APRES. +Format attendu: +{ + "results": [ + { + "codein": "123", + "recommandationGamme": "A", + "isDuplicate": false, + "justificationCourte": "Forte rotation et excellente marge." + } + ] +}`; + + const userPrompt = `Analyse cette liste de produits :\n${JSON.stringify(products, null, 2)}`; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "meta-llama/llama-3.3-70b-instruct:free", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt } + ], + response_format: { type: "json_object" }, // Force JSON + temperature: 0.1, // Low temp for analytical consistency + }), + }); + + if (!response.ok) { + const err = await response.text(); + console.error("OpenRouter API Error:", err); + return NextResponse.json({ error: "Erreur lors de l'appel à OpenRouter." }, { status: response.status }); + } + + const data = await response.json(); + const content = data.choices[0].message.content; + + // Clean up markdown markers if the model ignored response_format + const jsonMatch = content.match(/```json\n([\s\S]*?)\n```/) || content.match(/{[\s\S]*}/); + const jsonStr = jsonMatch ? jsonMatch[0].replace(/```json\n|\n```/g, '') : content; + + const resultJson = JSON.parse(jsonStr); + + return NextResponse.json(resultJson); + + } catch (error) { + console.error("Batch Analyze Error:", error); + return NextResponse.json({ error: "Erreur interne du serveur." }, { status: 500 }); + } +} diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx new file mode 100644 index 0000000..da04296 --- /dev/null +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -0,0 +1,142 @@ +"use client"; + +import React, { useState } from "react"; +import { useGridStore } from "@/features/grid/store/use-grid-store"; +import { Sparkles, Loader2, CheckCircle2 } from "lucide-react"; +import { ProductRow } from "@/types/grid"; + +export function BulkAiAnalyzer() { + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [progress, setProgress] = useState({ current: 0, total: 0, message: "" }); + const setDraftGamme = useGridStore(state => state.setDraftGamme); + + const handleAnalyze = async () => { + const { rows } = useGridStore.getState(); + if (rows.length === 0) return; + + setIsAnalyzing(true); + + 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); + }); + + // 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) + }); + } + } + + setProgress({ current: 0, total: chunks.length, message: "Initialisation..." }); + + // Process sequentially + let completed = 0; + for (const chunk of chunks) { + setProgress({ current: completed, total: chunks.length, message: `Analyse du rayon: ${chunk.rayon}` }); + + // Simplify payload to save tokens + const payloadProducts = chunk.items.map(r => ({ + codein: r.codein, + gtin: r.gtin, + nom: r.libelle1, + ventes: r.totalQuantite, + marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0, + })); + + const res = await fetch("/api/ai/batch-analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rayon: chunk.rayon, + products: payloadProducts + }) + }); + + if (!res.ok) { + console.error(`Erreur sur le lot ${chunk.rayon}`); + completed++; + continue; // Skip the failed chunk but continue processing + } + + try { + const data = await res.json(); + + // data.results should be an array + if (data && Array.isArray(data.results)) { + data.results.forEach((reco: any) => { + if (reco.codein && reco.recommandationGamme) { + // Add it to the store drafts + setDraftGamme(reco.codein, reco.recommandationGamme); + } + }); + } + } catch (e) { + console.error("Failed to parse JSON for chunk", chunk.rayon, e); + } + + completed++; + } + + setProgress({ current: completed, total: chunks.length, message: "Analyse terminée avec succès !" }); + 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); + } + }; + + if (isAnalyzing) { + return ( +
+ +
+ + {progress.message} + +
+
+
+
+
+ ); + } + + if (progress.current === progress.total && progress.total > 0 && !isAnalyzing) { + return ( +
+ + + Analyse terminée ({progress.total} lots analysés) + +
+ ); + } + + return ( + + ); +} diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 71ec906..72be552 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -2,6 +2,8 @@ import { useGridStore } from "@/features/grid/store/use-grid-store"; +import { BulkAiAnalyzer } from "./bulk-ai-analyzer"; + function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { // Determine color based on label to match the prototype const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white"; @@ -50,6 +52,8 @@ export function FloatingSummaryBar() {
+ +