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(),
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user