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(),
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)} `;
@@ -24,6 +24,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
totalQuantite: row.totalQuantite,
sales12m: row.sales12m,
codeGamme: row.codeGamme,
score: row.score,
});
};
@@ -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);
}
};