feat: add AnalysisEngine class for generating AI prompts and parsing recommendations for product analysis.

This commit is contained in:
Michael committed 2026-02-24 16:37:05 +01:00
1 parent 670ce76d4f
commit 5812254c6d
4 files changed
+255 -71

No files matched your search

@@ -2,77 +2,38 @@ import { ProductAnalysisInput } from "../models/ai-analysis.types";
export class AnalysisEngine {
static generateSystemPrompt(): string {
return `Tu es Mary, une experte Senior en Stratégie Retail et Category Management. Ton rôle est d'arbitrer l'assortiment avec une rigueur de consultant.
Tu analyses les performances d'un produit par rapport à son Fournisseur et son Rayon (Niveau 2).
return `Tu es Mary, experte Senior en Stratégie Retail. Ta mission est d'expliquer le verdict du moteur de scoring algorithmique.
Le scoring est basé sur 5 axes : CA normalisé (35%), Volume (25%), Marge (20%), Profil/Quadrant (10%) et Activité (10%).
--- HIÉRARCHIE DE DÉCISION MÉTIER (Priorité Décroissante) ---
--- TON RÔLE ---
1. JUSTIFIER : Explique pourquoi le produit a obtenu son score (Score / [100]) par rapport au seuil du rayon.
2. ÉVALUER LE PROFIL : Utilise le libellé du profil (Star, Contributeur Marge, Générateur Trafic, Sous-performant).
3. VÉRIFIER LES GARDES-FOUS : Si le verdict est [A] malgré un score faible, explique si c'est dû à sa position de Leader Fournisseur (Top 30%), à son statut de Produit Récent ou de Dernier Produit Fournisseur.
4. ÊTRE CONCISE : Maximum 2 phrases percutantes.
1. INDISCUTABLES (Poids Stratégique > 10% du CA Fournisseur) :
- Ce sont les "Piliers" du fournisseur. Ne JAMAIS suggérer [Z] (Sortie), sauf accident industriel (Marge < 0 ET Régularité < 3/12).
- Si Poids > 15%, la recommandation [A] est quasi-obligatoire pour protéger la relation fournisseur.
2. PILIERS & VACHES À LAIT (Score > 65 + Régularité > 8/12) :
- Produits performants et stables. Recommandation [A] (Permanent).
- Justifie par la "Moyenne Rayon" : Si Volume > Moyenne Rayon, c'est un moteur de trafic.
3. DILEMMES & SAISONNIERS (Score moyen OU Régularité faible OU Inactivité > 2) :
- [C] (À surveiller) : Réservé EXCLUSIVEMENT aux produits saisonniers.
- Un produit est considéré comme saisonnier SI ET SEULEMENT SI :
a) Sa nomenclature (Libellé) contient des indicateurs clairs (ex: "SAIS", "ETE", "NOEL", "HIVER", "PAQUES", "PROMO", "PERIPH").
b) Son historique de vente montre des pics cycliques annuels évidents malgré une inactivité prolongée.
- Ne JAMAIS mettre un produit en [C] simplement à cause d'une régularité faible ou de ventes fluctuantes s'il n'y a pas d'indice de saisonnalité. Dans ce cas, préférer [A] (si stratégique) ou [Z] (si performance insuffisante).
4. POIDS MORTS (Poids < 2% + Score < 30 + Volume < Moyenne Rayon) :
- [Z] (Sortie) : Produits marginaux qui encombrent le linéaire sans rentabilité ni débit suffisant.
--- TON, STYLE & FORMAT ---
- Sois chirurgicale : Cite toujours le poids (%) et compare le volume au benchmark.
- Maximum 2-3 phrases. Pas de blabla, juste des faits.
- Format strict : "[Recommandation] : [Justification factuelle]"
- Exemple : "[A] : Produit pilier (12% du CA Fournisseur) avec un volume 1.5x supérieur à la moyenne du rayon."`;
--- FORMAT ATTENDU ---
"[Recommandation] : [Justification factuelle incluant le Score et les axes clés]"`;
}
static generateUserMessage(p: ProductAnalysisInput): string {
const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0;
const monthlySummary = Object.entries(p.sales12m || {})
.map(([k, v]) => `${k}: ${Math.round(v)}u`)
.join(", ");
const volumeInfo = `VOLUMES : ${Math.round(p.totalQuantite)}u sur ${p.storeCount} mag.`;
// Contexte comparatif
const avgRef = p.avgQtyRayon || p.avgQtyFournisseur || 0;
const refName = p.avgQtyRayon ? "rayon" : "fournisseur";
const relativeVolume = avgRef > 0 ? ` (Moyenne du ${refName} : ${avgRef.toFixed(1)}u)` : "";
// Poids Stratégique
const weights = p.shareCa !== undefined ? `
CONTRIBUTION (%) :
- Chiffre d'Affaires : ${p.shareCa.toFixed(1)}% du total fournisseur
- Marge Brute : ${p.shareMarge?.toFixed(1)}% du total fournisseur
- Volumes : ${p.shareQty?.toFixed(1)}% du total fournisseur` : "";
- Marge Brute : ${p.shareMarge?.toFixed(1)}% du total fournisseur` : "";
const totalContext = p.totalFournisseurCa ? `\nVALEUR TOTALE FOURNISSEUR : ${p.totalFournisseurCa.toFixed(0)}€ CA` : "";
const projectionInfo = (p.regularityScore || 0) > 0 && (p.regularityScore || 0) < 12 ? `
--- ANALYSE DE POTENTIEL (Produit récent : ${p.regularityScore}/12 mois) ---
Projection 12 mois (Run Rate) : ${Math.round(p.projectedTotalQuantite || 0)}u (${(p.projectedTotalCa || 0).toFixed(2)}€)` : "";
const activityAlert = (p.inactivityMonths || 0) > 2
? `\n⚠️ ALERTE INACTIVITÉ : ${p.inactivityMonths} mois sans vente.`
: "";
const scoringInfo = p.scoring ? `
--- RÉSULTATS SCORING ALGORIHTMIQUE ---
SCORE FINAL : ${p.scoring.compositeScore}/100 (Seuil Rayon Z : ${p.scoring.threshold})
PROFIL : ${p.scoring.labelProfil}
GARDES-FOUS : ${p.scoring.isTop30Supplier ? "Oui (Top 30% Fournisseur)" : "Non"} | Récent : ${p.scoring.isRecent ? "Oui" : "Non"} | Dernier Prod : ${p.scoring.isLastProduct ? "Oui" : "Non"}
` : "";
return `PRODUIT : ${p.libelle1} (${p.codein})
PERFORMANCE : Score ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€
${volumeInfo}${relativeVolume}${weights}${totalContext}
CYCLE DE VIE : ${p.regularityScore}/12 mois active${projectionInfo}${activityAlert}
--- HISTORIQUE MENSUEL PONDÉRÉ ---
${monthlySummary}
Recommandation actuelle : ${p.codeGamme || "Aucune"}
Verdict attendu (A, C ou Z) ?`;
PERFORMANCE : Score Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€
${weights}${scoringInfo}
Verdict algorithmique : ${p.scoring?.decision || "Non calculé"}
Justifie ce verdict auprès de l'utilisateur.`;
}
static extractRecommendation(content: string): "A" | "C" | "Z" | null {
@@ -0,0 +1,177 @@
import { ProductAnalysisInput } from "../models/ai-analysis.types";
export interface ScoringResult {
compositeScore: number;
percentiles: {
ca: number;
volume: number;
marge: number;
};
scores: {
profil: number;
activite: number;
};
decision: {
recommendation: "A" | "Z";
threshold: number;
isTop30Supplier: boolean;
isLastProduct: boolean;
isRecent: boolean;
labelProfil: string;
};
metrics: {
caNormalise: number;
volNormalise: number;
};
}
export class ScoringEngine {
/**
* Calcule le score et la décision pour un produit au sein de son rayon.
*/
static analyzeRayon(targetProduct: ProductAnalysisInput, allProducts: ProductAnalysisInput[]): ScoringResult {
// 1. Normalisation et Projection pour tous les produits du rayon
const processedProducts = allProducts.map(p => this.preprocess(p));
const target = this.preprocess(targetProduct);
// 2. Calcul des Percentiles (Rangs / Nb produits)
const pCa = this.calculatePercentile(target.caNormalise, processedProducts.map(p => p.caNormalise));
const pVol = this.calculatePercentile(target.volNormalise, processedProducts.map(p => p.volNormalise));
const pMarge = this.calculatePercentile(target.tauxMarge, processedProducts.map(p => p.tauxMarge));
// 3. Score Profil (Quadrant vs Médianes)
const medVol = this.calculateMedian(processedProducts.map(p => p.volNormalise));
const medMarge = this.calculateMedian(processedProducts.map(p => p.tauxMarge));
let scoreProfil = 0.2;
let labelProfil = "Sous-performant ⚠️";
if (target.volNormalise > medVol && target.tauxMarge > medMarge) {
scoreProfil = 1.0;
labelProfil = "Star ⭐";
} else if (target.volNormalise <= medVol && target.tauxMarge > medMarge) {
scoreProfil = 0.7;
labelProfil = "Contributeur Marge 💎";
} else if (target.volNormalise > medVol && target.tauxMarge <= medMarge) {
scoreProfil = 0.7;
labelProfil = "Générateur Trafic 🚶";
}
// 4. Score Activité (Inactivité corrigée saisonnalité)
const rayonInactivityRate = processedProducts.filter(p => (p.inactivityMonths || 0) > 0).length / processedProducts.length;
const isSaisonnierRayon = rayonInactivityRate > 0.40;
let scoreActivite = 1.0;
const inactif = target.inactivityMonths || 0;
if (isSaisonnierRayon && inactif > 0) {
scoreActivite = 0.8;
} else if (inactif === 1) {
scoreActivite = 0.6;
} else if (inactif === 2) {
scoreActivite = 0.3;
} else if (inactif >= 3) {
scoreActivite = 0.0;
}
// 5. Score Composite
const compositeScore = (pCa * 0.35) + (pVol * 0.25) + (pMarge * 0.20) + (scoreProfil * 0.10) + (scoreActivite * 0.10);
// 6. Décision (Moyenne - Sigma)
const allScores = processedProducts.map(p => {
// On doit simuler le score composite pour tout le monde pour avoir la moyenne/sigma
// Pour simplifier ici, on recalculerait ça proprement
return this.calculateQuickScore(p, processedProducts, medVol, medMarge, isSaisonnierRayon);
});
const mean = allScores.reduce((a, b) => a + b, 0) / allScores.length;
const stdDev = Math.sqrt(allScores.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b, 0) / allScores.length);
const threshold = mean - stdDev;
let recommendation: "A" | "Z" = compositeScore >= threshold ? "A" : "Z";
// 7. Gardes-fous
const isRecent = (target.regularityScore || 0) < 3;
const isTop30Supplier = this.checkTop30Supplier(target, processedProducts);
const isLastProduct = target.isLastProductOfSupplier || false;
if (isRecent || isTop30Supplier || isLastProduct) {
recommendation = "A";
}
return {
compositeScore: Math.round(compositeScore * 100),
percentiles: { ca: pCa, volume: pVol, marge: pMarge },
scores: { profil: scoreProfil, activite: scoreActivite },
decision: {
recommendation,
threshold: Math.round(threshold * 100),
isTop30Supplier,
isLastProduct,
isRecent,
labelProfil
},
metrics: {
caNormalise: target.caNormalise,
volNormalise: target.volNormalise
}
};
}
private static preprocess(p: ProductAnalysisInput) {
const coef = (p.totalMagasins || 1) / (p.storeCount || 1);
let caNorm = (p.totalCa || 0) * coef;
let volNorm = (p.totalQuantite || 0) * coef;
// Run Rate Projection
if ((p.regularityScore || 0) >= 3 && (p.regularityScore || 0) < 12) {
caNorm = (caNorm / (p.regularityScore || 3)) * 12;
volNorm = (volNorm / (p.regularityScore || 3)) * 12;
}
return { ...p, caNormalise: caNorm, volNormalise: volNorm };
}
private static calculatePercentile(value: number, distribution: number[]): number {
if (distribution.length <= 1) return 1;
const sorted = [...distribution].sort((a, b) => a - b);
const rank = sorted.indexOf(value);
return rank / (distribution.length - 1);
}
private static calculateMedian(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
private static checkTop30Supplier(target: any, allProducts: any[]): boolean {
const supplierProds = allProducts.filter(p => p.codeFournisseur === target.codeFournisseur);
if (supplierProds.length === 0) return false;
const sorted = supplierProds.sort((a, b) => b.caNormalise - a.caNormalise);
const topCount = Math.ceil(supplierProds.length * 0.30);
const topIds = sorted.slice(0, topCount).map(p => p.codein);
return topIds.includes(target.codein);
}
private static calculateQuickScore(p: any, rayon: any[], medVol: number, medMarge: number, isSaisonnier: boolean): number {
const pCa = this.calculatePercentile(p.caNormalise, rayon.map(x => x.caNormalise));
const pVol = this.calculatePercentile(p.volNormalise, rayon.map(x => x.volNormalise));
const pMarge = this.calculatePercentile(p.tauxMarge, rayon.map(x => x.tauxMarge));
let sProfil = 0.2;
if (p.volNormalise > medVol && p.tauxMarge > medMarge) sProfil = 1.0;
else if (p.volNormalise <= medVol && p.tauxMarge > medMarge) sProfil = 0.7;
else if (p.volNormalise > medVol && p.tauxMarge <= medMarge) sProfil = 0.7;
let sActivite = 1.0;
const inactif = p.inactivityMonths || 0;
if (isSaisonnier && inactif > 0) sActivite = 0.8;
else if (inactif === 1) sActivite = 0.6;
else if (inactif === 2) sActivite = 0.3;
else if (inactif >= 3) sActivite = 0.0;
return (pCa * 0.35) + (pVol * 0.25) + (pMarge * 0.20) + (sProfil * 0.10) + (sActivite * 0.10);
}
}
@@ -3,12 +3,14 @@
import React, { useState } from "react";
import { Bot, Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import { AiExplanationModal } from "./ai-explanation-modal";
import { useAiCopilotStore } from "../store/use-ai-copilot-store";
import { ProductAnalysisInput } from "../models/ai-analysis.types";
import { ScoringEngine } from "../business/scoring-engine";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import type { ProductRow } from "@/types/grid";
import { cn } from "@/lib/utils";
import { useGridStore } from "@/features/grid/store/use-grid-store";
interface AiInsightBlockProps {
row: ProductRow;
@@ -30,18 +32,17 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
setLoading(row.codein);
setDraftGamme(row.codein, "Aucune");
const regScore = Object.values(row.sales12m || {}).filter(v => v > 0).length;
const allRows = useGridStore.getState().rows;
const rayonRows = allRows.filter(r => r.libelleNiveau2 === row.libelleNiveau2);
// Préparation du ScoringEngine
const regScore = Object.values(row.sales12m || {}).filter((v: any) => v > 0).length;
const weight = (row.workingStores?.length || 1) === 1 ? 2 : 1;
// Calcul de l'inactivité (Récence)
// Calcul de l'inactivité
const allMonths = Object.keys(row.sales12m || {});
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : "";
const salesMonths = Object.entries(row.sales12m || {})
.filter(([_, qty]) => qty > 0)
.map(([m]) => parseInt(m))
.sort((a, b) => b - a);
const salesMonths = Object.entries(row.sales12m || {}).filter(([_, qty]: [string, any]) => qty > 0).map(([m]) => parseInt(m)).sort((a, b) => b - a);
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
let inactivity = 0;
if (referenceMonth && lastMonth) {
@@ -52,6 +53,27 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
inactivity = (refY - lastY) * 12 + (refM - lastM);
}
// Mapping pour le moteur de scoring
const mapToInput = (r: ProductRow) => ({
codein: r.codein,
libelle1: r.libelle1,
totalCa: r.totalCa,
tauxMarge: r.tauxMarge,
totalQuantite: r.totalQuantite,
codeFournisseur: r.codeFournisseur,
storeCount: r.workingStores?.length || 1,
totalMagasins: 2,
regularityScore: Object.values(r.sales12m || {}).filter((v: any) => v > 0).length,
inactivityMonths: 0,
isLastProductOfSupplier: false,
sales12m: r.sales12m,
codeGamme: r.codeGamme,
score: r.score
});
// Calcul du ScoringEngine
const scoringResult = ScoringEngine.analyzeRayon(mapToInput(row), rayonRows.map(mapToInput));
analyzeProduct({
codein: row.codein,
libelle1: row.libelle1,
@@ -62,8 +84,6 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
codeGamme: row.codeGamme,
score: row.score,
regularityScore: regScore,
projectedTotalQuantite: regScore > 0 ? (row.totalQuantite * weight * (12 / regScore)) : (row.totalQuantite * weight),
projectedTotalCa: regScore > 0 ? (row.totalCa * weight * (12 / regScore)) : (row.totalCa * weight),
lastMonthWithSale: lastMonth,
inactivityMonths: inactivity,
avgQtyFournisseur: row.avgQtyFournisseur,
@@ -72,6 +92,17 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
shareMarge: row.shareMarge,
shareQty: row.shareQty,
totalFournisseurCa: row.totalFournisseurCa,
codeFournisseur: row.codeFournisseur,
totalMagasins: 2,
scoring: {
compositeScore: scoringResult.compositeScore,
decision: scoringResult.decision.recommendation,
labelProfil: scoringResult.decision.labelProfil,
isTop30Supplier: scoringResult.decision.isTop30Supplier,
isRecent: scoringResult.decision.isRecent,
isLastProduct: scoringResult.decision.isLastProduct,
threshold: scoringResult.decision.threshold
}
});
};
@@ -27,10 +27,25 @@ export interface ProductAnalysisInput {
/** Analyse de saisonnalité */
lastMonthWithSale?: string;
inactivityMonths?: number;
/** Contexte Fournisseur */
codeFournisseur?: string;
totalMagasins?: number;
isLastProductOfSupplier?: boolean;
/** Résultats du ScoringEngine (optionnel pour l'IA) */
scoring?: {
compositeScore: number;
decision: string;
labelProfil: string;
isTop30Supplier: boolean;
isRecent: boolean;
isLastProduct: boolean;
threshold: number;
};
}
export interface AnalysisResult {
insight: string;
codein: string;
recommandation: "A" | "C" | "Z" | null;
scoring?: any; // On peut typer plus finement si nécessaire
}