feat(ai): integrate performance score for smarter analysis and parallelize batch processing

This commit is contained in:
Michael committed 2026-02-21 22:41:10 +01:00
1 parent 4dac21dfac
commit 5c8910825b
3 files changed
+37 -31

No files matched your search

+10 -6
View File
@@ -10,18 +10,21 @@ const AnalyzeSchema = z.object({
totalQuantite: z.number(), totalQuantite: z.number(),
sales12m: z.record(z.string(), z.number()), sales12m: z.record(z.string(), z.number()),
codeGamme: z.string().nullable(), codeGamme: z.string().nullable(),
score: z.number().nullable().optional(),
}); });
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const SYSTEM_PROMPT = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel. const SYSTEM_PROMPT = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel.
En analysant les données de ventes fournies, génère une recommandation de gamme (A=Permanent, C=Saisonnier, Z=Sortie). En analysant les données de ventes et le SCORE de performance relative (0-100), génère une recommandation de gamme (A=Permanent, C=Saisonnier, Z=Sortie).
- A : Produit permanent avec rotation régulière.
- C : Produit saisonnier (pics de ventes spécifiques). RÈGLES CRITIQUES :
- Z : Produit en sortie (aucune vente ou rotation insignifiante). - SCORE < 20 : Signal fort de "Sortie" (Z). Même si le volume semble correct dans l'absolu, un score faible signifie que le produit sous-performe par rapport à ses pairs.
- A (Permanent) : Uniquement si la rotation est régulière ET le score est satisfaisant.
- C (Saisonnier) : Uniquement si des pics clairs apparaissent dans l'historique.
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable. Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
Format: "[Recommandation]: [Justification courte basée sur les données]"`; Format: "[Recommandation]: [Justification courte basée sur les données et le score]"`;
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
console.log("[AI] Analysis request received."); console.log("[AI] Analysis request received.");
@@ -42,7 +45,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 }); return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
} }
const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme } = parsed.data; const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme, score } = parsed.data;
// Format monthly sales for the prompt // Format monthly sales for the prompt
const monthlySummary = Object.entries(sales12m) const monthlySummary = Object.entries(sales12m)
@@ -51,6 +54,7 @@ export async function POST(req: NextRequest) {
const userMessage = `Produit: "${libelle1}" (Code: ${codein}) const userMessage = `Produit: "${libelle1}" (Code: ${codein})
Gamme actuelle: ${codeGamme ?? "Non définie"} Gamme actuelle: ${codeGamme ?? "Non définie"}
Score de performance: ${score ?? "N/A"}/100
CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités
Historique mensuel: ${monthlySummary} Historique mensuel: ${monthlySummary}
+26 -24
View File
@@ -11,6 +11,7 @@ const BatchAnalyzeSchema = z.object({
ca: z.number().nullable().optional(), ca: z.number().nullable().optional(),
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(),
gammeInit: z.string().nullable().optional(), gammeInit: z.string().nullable().optional(),
historique: z.string().nullable().optional(), historique: z.string().nullable().optional(),
nomenclature: z.string().nullable().optional(), nomenclature: z.string().nullable().optional(),
@@ -41,39 +42,40 @@ export async function POST(req: NextRequest) {
// Optimized System Prompt: Merged from analyze/route.ts as requested by user // Optimized System Prompt: Merged from analyze/route.ts as requested by user
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), 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) 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 : CRITÈRES PRIORITAIRES :
- A (Permanent) : Produit permanent avec une rotation régulière et satisfaisante sur toute l'année. - 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.
- C (Saisonnier) : Produit dont les ventes sont concentrées sur des périodes spécifiques. Détecte les pics dans l' 'historique' (ex: ventes en 202412 mais rien ailleurs). Aide-toi de la 'nomenclature' et du 'nom' pour confirmer l'usage saisonnier. - A (Permanent) : Produit avec une rotation régulière ET un score satisfaisant (> 30-40).
- Z (Sortie) : Produit à arrêter car les ventes sont quasi nulles ou la rotation est insuffisante. - C (Saisonnier) : Pics de ventes concentrés sur l'historique. Aide-toi de la nomenclature.
- Z (Sortie) : Ventes nulles, rotation insuffisante ou score médiocre.`;
DÉTECTION : DÉTECTION:
Marque 'isDuplicate: true' si le produit semble être un doublon dans le lot. 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:
{ {
"results": [ "results": [
{ {
"codein": "ID", "codein": "ID",
"recommandationGamme": "A|C|Z", "recommandationGamme": "A|C|Z",
"isDuplicate": boolean, "isDuplicate": boolean,
"justificationCourte": "..." "justificationCourte": "..."
} }
] ]
} }
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. 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: 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) } `;
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${apiKey}`, "Authorization": `Bearer ${ apiKey } `,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -91,7 +93,7 @@ Données : codein, nom, ca (€), ventes (unités), marge (%), gammeInit, histor
// Read the retry-after header and pass it back to the client // Read the retry-after header and pass it back to the client
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests"); const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
console.warn(`[batch-analyze] Rate limited. Retry after ${waitSeconds}s.`); console.warn(`[batch - analyze] Rate limited.Retry after ${ waitSeconds } s.`);
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 }); return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
} }
@@ -112,7 +114,7 @@ Données : codein, nom, ca (€), ventes (unités), marge (%), gammeInit, histor
// Try to extract JSON block from the response (handles markdown code blocks and raw JSON) // Try to extract JSON block from the response (handles markdown code blocks and raw JSON)
let jsonStr = content; let jsonStr = content;
const jsonBlock = content.match(/```json\s*([\s\S]*?)\s*```/); const jsonBlock = content.match(/```json\s * ([\s\S] *?) \s * ```/);
if (jsonBlock) { if (jsonBlock) {
jsonStr = jsonBlock[1]; jsonStr = jsonBlock[1];
} else { } else {
@@ -36,7 +36,7 @@ export const useAiCopilotStore = create<AiCopilotState>((set) => ({
}, },
analyzeProduct: async (payload) => { analyzeProduct: async (payload) => {
const { codein } = payload; const { codein, score } = payload;
// Mark as loading // Mark as loading
set((s) => ({ set((s) => ({