feat(ai): synchronize batch and individual analysis payloads and models, optimize concurrency

This commit is contained in:
Michael committed 2026-02-21 22:48:16 +01:00
1 parent 66f28956ce
commit e8cc89cb2f
3 files changed
+42 -58

No files matched your search

+9 -14
View File
@@ -12,8 +12,8 @@ const BatchAnalyzeSchema = z.object({
ventes: z.number().nullable().optional(), ventes: z.number().nullable().optional(),
marge: z.number().nullable().optional(), marge: z.number().nullable().optional(),
score: z.number().nullable().optional(), score: z.number().nullable().optional(),
gammeInit: z.string().nullable().optional(), codeGamme: z.string().nullable().optional(),
historique: z.string().nullable().optional(), sales12m: z.record(z.string(), z.number()).nullable().optional(),
nomenclature: z.string().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) // Read the API key from DB config (same pattern as /api/ai/analyze)
const config = await getSavedDatabaseConfig(); const config = await getSavedDatabaseConfig();
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; 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) { if (!apiKey) {
console.error("[batch-analyze] API key manquante."); console.error("[batch-analyze] API key manquante.");
@@ -39,20 +39,17 @@ export async function POST(req: NextRequest) {
const { rayon, products } = parsed.data; 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. 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 : 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. - 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 (> 30-40). - A (Permanent) : Produit avec une rotation régulière ET un score satisfaisant (> 35).
- C (Saisonnier) : Pics de ventes concentrés sur l'historique. Aide-toi de la nomenclature. - 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. - 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. IMPORTANT : RÉPONDS UNIQUEMENT EN JSON VALIDE.
Format: 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 fournies : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), codeGamme (actuel), sales12m (historique par mois), nomenclature.`;
Données : codein, nom, ca (€), ventes (unités), marge (%), gammeInit, historique, nomenclature.`;
const userPrompt = `Analyse cette liste de produits: \n${JSON.stringify(products, null, 2)} `; const userPrompt = `Analyse cette liste de produits: \n${JSON.stringify(products, null, 2)} `;
@@ -24,6 +24,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
totalQuantite: row.totalQuantite, totalQuantite: row.totalQuantite,
sales12m: row.sales12m, sales12m: row.sales12m,
codeGamme: row.codeGamme, codeGamme: row.codeGamme,
score: row.score,
}); });
}; };
@@ -17,6 +17,7 @@ export function BulkAiAnalyzer() {
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows"); console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows");
setIsAnalyzing(true); setIsAnalyzing(true);
let completed = 0;
try { try {
// Group by Nomenclature (libelle3 or a fallback) // Group by Nomenclature (libelle3 or a fallback)
@@ -27,39 +28,34 @@ export function BulkAiAnalyzer() {
groups[rayon].push(r); groups[rayon].push(r);
}); });
// Create chunks of up to 50 products per rayon
const CHUNK_SIZE = 50; const CHUNK_SIZE = 50;
const chunks: { rayon: string; items: ProductRow[] }[] = []; const chunks: { rayon: string; items: ProductRow[] }[] = [];
for (const [rayon, items] of Object.entries(groups)) { for (const [rayon, items] of Object.entries(groups)) {
for (let i = 0; i < items.length; i += CHUNK_SIZE) { for (let i = 0; i < items.length; i += CHUNK_SIZE) {
chunks.push({ chunks.push({ rayon, items: items.slice(i, i + CHUNK_SIZE) });
rayon,
items: items.slice(i, i + CHUNK_SIZE)
});
} }
} }
setProgress({ current: 0, total: chunks.length, message: "Initialisation...", errors: 0 }); setProgress({ current: 0, total: chunks.length, message: "Initialisation...", errors: 0 });
// Process sequentially // Process with a continuous concurrency limit
let completed = 0; const CONCURRENCY = 3;
for (const chunk of chunks) { const remainingChunks = [...chunks];
setProgress(prev => ({ ...prev, current: completed, total: chunks.length, message: `Analyse du rayon: ${chunk.rayon}` }));
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 => ({ const payloadProducts = chunk.items.map(r => ({
codein: r.codein, codein: r.codein,
nom: r.libelle1, nom: r.libelle1,
ca: r.totalCa || 0, ca: r.totalCa || 0,
ventes: r.totalQuantite || 0, ventes: r.totalQuantite || 0,
marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0, marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0,
gammeInit: r.codeGammeInit || "N/A", score: r.score || 0,
// Detailed history: "YYYYMM: Qty, YYYYMM: Qty..." to help AI see seasonality codeGamme: r.codeGamme || "N/A",
historique: Object.entries(r.sales12m || {}) sales12m: r.sales12m || {}, // Send raw object for better AI analysis
.map(([month, qty]) => `${month}:${qty}`)
.join(", "),
// Contextual nomenclature
nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}` nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`
})); }));
@@ -74,66 +70,58 @@ export function BulkAiAnalyzer() {
body: JSON.stringify({ rayon: chunk.rayon, products: payloadProducts }) 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 errData = await res.json().catch(() => ({}));
const waitSecs = errData.retryAfter ?? (30 * attempt); 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--) { 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)); await new Promise(r => setTimeout(r, 1000));
} }
continue; // retry continue;
} }
break; // success or non-retryable error break;
} }
if (!res || !res.ok) { if (!res || !res.ok) {
const errText = await res?.text().catch(() => ""); 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 })); setProgress(prev => ({ ...prev, errors: prev.errors + 1 }));
} else { } else {
const data = await res.json(); const data = await res.json();
console.log(`[BulkAiAnalyzer] Response for ${chunk.rayon}:`, data);
if (data && Array.isArray(data.results)) { if (data && Array.isArray(data.results)) {
let applied = 0;
data.results.forEach((reco: any) => { data.results.forEach((reco: any) => {
if (reco.codein && reco.recommandationGamme) { if (reco.codein && reco.recommandationGamme) {
setDraftGamme(reco.codein, reco.recommandationGamme); setDraftGamme(reco.codein, reco.recommandationGamme);
const baseJustification = reco.justificationCourte || "Aucune explication."; const justification = `Gamme ${reco.recommandationGamme} — ${reco.justificationCourte || "Analyse effectuée."}`;
const justification = `Gamme ${reco.recommandationGamme} — ${baseJustification}`;
setInsight(reco.codein, justification, reco.isDuplicate ?? false); 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) { } catch (e) {
console.error(`[BulkAiAnalyzer] Failed fetch for chunk ${chunk.rayon}:`, e); console.error(`[BulkAiAnalyzer] Failed fetch for chunk ${chunk.rayon}:`, e);
setProgress(prev => ({ ...prev, errors: prev.errors + 1 })); 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++; // Start workers
const workers = Array.from({ length: Math.min(CONCURRENCY, chunks.length) }, () => processNext());
// Always wait 20s between chunks — free tier allows ~3 req/min await Promise.all(workers);
if (completed < chunks.length) {
setProgress(prev => ({ ...prev, current: completed, message: `En attente... (lot ${completed}/${chunks.length})` }));
await new Promise(resolve => setTimeout(resolve, 20000));
}
}
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: chunks.length, message: `Analyse terminée ! (${prev.errors > 0 ? prev.errors + " erreurs" : "succès"})` }));
setTimeout(() => { setTimeout(() => setIsAnalyzing(false), 3000);
setIsAnalyzing(false);
}, 3000);
} catch (error) { } catch (error) {
console.error("Erreur globale lors de l'analyse:", error); console.error("Erreur globale lors de l'analyse:", error);
alert("Une erreur inattendue s'est produite pendant l'analyse globale.");
setIsAnalyzing(false); setIsAnalyzing(false);
} }
}; };