mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 21:18:16 +02:00
feat(ai): integrate Llama 3.3 batch analysis for products grouped by nomenclature
This commit is contained in:
1 parent
09e00fbcdd
commit
a07bad60b2
3 files changed
+244
No files matched your search
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, ProductRow[]> = {};
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800">
|
||||||
|
<Loader2 className="w-4 h-4 text-indigo-500 animate-spin" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-bold text-indigo-700 dark:text-indigo-400">
|
||||||
|
{progress.message}
|
||||||
|
</span>
|
||||||
|
<div className="w-full bg-indigo-200 dark:bg-indigo-800/50 rounded-full h-1.5 mt-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-indigo-500 h-1.5 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${Math.max(5, (progress.current / progress.total) * 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.current === progress.total && progress.total > 0 && !isAnalyzing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 cursor-default">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-500" />
|
||||||
|
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">
|
||||||
|
Analyse terminée ({progress.total} lots analysés)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
className="group flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all shadow-sm bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:shadow-md hover:brightness-110 active:scale-95 border border-indigo-400"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Analyse IA Globale
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
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 }) {
|
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
// Determine color based on label to match the prototype
|
// 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";
|
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() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-3 items-center">
|
<div className="flex space-x-3 items-center">
|
||||||
|
<BulkAiAnalyzer />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// Fetch the absolute latest state from the store at the moment of the click
|
// Fetch the absolute latest state from the store at the moment of the click
|
||||||
|
|||||||
Reference in new issue
Block a user