From f824476e0b8c3411b7a90d5cea96847c143b6595 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 24 Feb 2026 16:49:53 +0100 Subject: [PATCH] feat(ai): implement ScoringEngine v5 and update Mary AI logic --- .../ai-copilot/business/analysis-engine.ts | 35 +++++++++++-------- .../ai-copilot/business/scoring-engine.ts | 33 ++++++++++++----- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 7da0eff..21229ea 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -3,13 +3,20 @@ import { ProductAnalysisInput } from "../models/ai-analysis.types"; export class AnalysisEngine { static generateSystemPrompt(): string { 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%). +Le score (0 à 100) est une mesure de performance RELATIVE au sein du rayon. + +--- INTERPRÉTATION DU SCORE --- +- 100/100 : Excellence absolue (Élite du rayon). +- > 70/100 : Très bonne performance. +- 30-70/100 : Performance moyenne. +- < 30/100 : Performance faible. +- 0/100 : Aucune performance enregistrée (ou dernier du classement). --- 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. JUSTIFIER : Explique le score par les axes (CA, Volume, Marge). Ne dis JAMAIS que le score est une erreur ou "non calculé" s'il est présent. +2. EXPLIQUER LE VERDICT [A] : Si le verdict est [A] malgré un score faible, c'est une PROTECTION MÉTIER (ex: Produit Récent, Leader Fournisseur, Dernier Produit). Explique cette protection avec bienveillance. +3. PROFIL : Utilise le libellé du profil fourni (Star, Contributeur Marge, etc.). +4. CONCISE : 2 phrases maximum. Pas de blabla technique sur les percentiles, parle de "performance relative". --- FORMAT ATTENDU --- "[Recommandation] : [Justification factuelle incluant le Score et les axes clés]"`; @@ -18,21 +25,21 @@ Le scoring est basé sur 5 axes : CA normalisé (35%), Volume (25%), Marge (20%) static generateUserMessage(p: ProductAnalysisInput): string { const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0; const weights = p.shareCa !== undefined ? ` -CONTRIBUTION (%) : +CONTRIBUTION(%) : - Chiffre d'Affaires : ${p.shareCa.toFixed(1)}% du total fournisseur -- Marge Brute : ${p.shareMarge?.toFixed(1)}% du total fournisseur` : ""; + - Marge Brute: ${p.shareMarge?.toFixed(1)}% du total fournisseur` : ""; 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"} +--- 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 Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€ + return `PRODUIT: ${p.libelle1} (${p.codein}) +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é"} +Verdict algorithmique: ${p.scoring?.decision || "Non calculé"} Justifie ce verdict auprès de l'utilisateur.`; } diff --git a/src/features/ai-copilot/business/scoring-engine.ts b/src/features/ai-copilot/business/scoring-engine.ts index b05ed82..44853b2 100644 --- a/src/features/ai-copilot/business/scoring-engine.ts +++ b/src/features/ai-copilot/business/scoring-engine.ts @@ -79,24 +79,31 @@ export class ScoringEngine { // 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; + const variance = allScores.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b, 0) / allScores.length; + const stdDev = Math.sqrt(variance); + const threshold = Math.max(0.1, mean - 1.0 * stdDev); // Seuil plancher à 10/100 let recommendation: "A" | "Z" = compositeScore >= threshold ? "A" : "Z"; - // 7. Gardes-fous + // 7. Gardes-fous et Labels const isRecent = (target.regularityScore || 0) < 3; const isTop30Supplier = this.checkTop30Supplier(target, processedProducts); const isLastProduct = target.isLastProductOfSupplier || false; - if (isRecent || isTop30Supplier || isLastProduct) { + let finalLabel = labelProfil; + if (isRecent) { recommendation = "A"; + finalLabel = "Nouveauté (Protégée)"; + } else if (isTop30Supplier) { + recommendation = "A"; + finalLabel = `${labelProfil} / Leader Fournisseur (Protégé)`; + } else if (isLastProduct) { + recommendation = "A"; + finalLabel = "Dernière Réf Fournisseur (Protégée)"; } return { @@ -109,7 +116,7 @@ export class ScoringEngine { isTop30Supplier, isLastProduct, isRecent, - labelProfil + labelProfil: finalLabel }, metrics: { caNormalise: target.caNormalise, @@ -135,8 +142,16 @@ export class ScoringEngine { 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); + + // Trouver tous les index de la valeur pour gérer les ex-aequos + const first = sorted.indexOf(value); + const last = sorted.lastIndexOf(value); + + if (first === -1) return 0; + + // Rang moyen + const avgRank = (first + last) / 2; + return avgRank / (distribution.length - 1); } private static calculateMedian(values: number[]): number {