mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: add AnalysisEngine class for generating AI prompts and parsing recommendations for product analysis.
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user