mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(ai): synchronize batch and individual analysis payloads and models, optimize concurrency
This commit is contained in:
1 parent
66f28956ce
commit
e8cc89cb2f
3 files changed
+42
-58
No files matched your search
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user