mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-24 07:28:10 +02:00
feat(ai): implement ScoringEngine v5 and update Mary AI logic
This commit is contained in:
1 parent
5812254c6d
commit
f824476e0b
2 files changed
+45
-23
No files matched your search
@@ -3,13 +3,20 @@ import { ProductAnalysisInput } from "../models/ai-analysis.types";
|
|||||||
export class AnalysisEngine {
|
export class AnalysisEngine {
|
||||||
static generateSystemPrompt(): string {
|
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.
|
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 ---
|
--- TON RÔLE ---
|
||||||
1. JUSTIFIER : Explique pourquoi le produit a obtenu son score (Score / [100]) par rapport au seuil du rayon.
|
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. ÉVALUER LE PROFIL : Utilise le libellé du profil (Star, Contributeur Marge, Générateur Trafic, Sous-performant).
|
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. 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.
|
3. PROFIL : Utilise le libellé du profil fourni (Star, Contributeur Marge, etc.).
|
||||||
4. ÊTRE CONCISE : Maximum 2 phrases percutantes.
|
4. CONCISE : 2 phrases maximum. Pas de blabla technique sur les percentiles, parle de "performance relative".
|
||||||
|
|
||||||
--- FORMAT ATTENDU ---
|
--- FORMAT ATTENDU ---
|
||||||
"[Recommandation] : [Justification factuelle incluant le Score et les axes clés]"`;
|
"[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 {
|
static generateUserMessage(p: ProductAnalysisInput): string {
|
||||||
const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0;
|
const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0;
|
||||||
const weights = p.shareCa !== undefined ? `
|
const weights = p.shareCa !== undefined ? `
|
||||||
CONTRIBUTION (%) :
|
CONTRIBUTION(%) :
|
||||||
- Chiffre d'Affaires : ${p.shareCa.toFixed(1)}% du total fournisseur
|
- 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 ? `
|
const scoringInfo = p.scoring ? `
|
||||||
--- RÉSULTATS SCORING ALGORIHTMIQUE ---
|
--- RÉSULTATS SCORING ALGORIHTMIQUE-- -
|
||||||
SCORE FINAL : ${p.scoring.compositeScore}/100 (Seuil Rayon Z : ${p.scoring.threshold})
|
SCORE FINAL: ${p.scoring.compositeScore}/100 (Seuil Rayon Z : ${p.scoring.threshold})
|
||||||
PROFIL : ${p.scoring.labelProfil}
|
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"}
|
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})
|
return `PRODUIT: ${p.libelle1} (${p.codein})
|
||||||
PERFORMANCE : Score Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€
|
PERFORMANCE: Score Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€
|
||||||
${weights}${scoringInfo}
|
${weights}${scoringInfo}
|
||||||
Verdict algorithmique : ${p.scoring?.decision || "Non calculé"}
|
Verdict algorithmique: ${p.scoring?.decision || "Non calculé"}
|
||||||
Justifie ce verdict auprès de l'utilisateur.`;
|
Justifie ce verdict auprès de l'utilisateur.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,24 +79,31 @@ export class ScoringEngine {
|
|||||||
|
|
||||||
// 6. Décision (Moyenne - Sigma)
|
// 6. Décision (Moyenne - Sigma)
|
||||||
const allScores = processedProducts.map(p => {
|
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);
|
return this.calculateQuickScore(p, processedProducts, medVol, medMarge, isSaisonnierRayon);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mean = allScores.reduce((a, b) => a + b, 0) / allScores.length;
|
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 variance = allScores.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b, 0) / allScores.length;
|
||||||
const threshold = mean - stdDev;
|
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";
|
let recommendation: "A" | "Z" = compositeScore >= threshold ? "A" : "Z";
|
||||||
|
|
||||||
// 7. Gardes-fous
|
// 7. Gardes-fous et Labels
|
||||||
const isRecent = (target.regularityScore || 0) < 3;
|
const isRecent = (target.regularityScore || 0) < 3;
|
||||||
const isTop30Supplier = this.checkTop30Supplier(target, processedProducts);
|
const isTop30Supplier = this.checkTop30Supplier(target, processedProducts);
|
||||||
const isLastProduct = target.isLastProductOfSupplier || false;
|
const isLastProduct = target.isLastProductOfSupplier || false;
|
||||||
|
|
||||||
if (isRecent || isTop30Supplier || isLastProduct) {
|
let finalLabel = labelProfil;
|
||||||
|
if (isRecent) {
|
||||||
recommendation = "A";
|
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 {
|
return {
|
||||||
@@ -109,7 +116,7 @@ export class ScoringEngine {
|
|||||||
isTop30Supplier,
|
isTop30Supplier,
|
||||||
isLastProduct,
|
isLastProduct,
|
||||||
isRecent,
|
isRecent,
|
||||||
labelProfil
|
labelProfil: finalLabel
|
||||||
},
|
},
|
||||||
metrics: {
|
metrics: {
|
||||||
caNormalise: target.caNormalise,
|
caNormalise: target.caNormalise,
|
||||||
@@ -135,8 +142,16 @@ export class ScoringEngine {
|
|||||||
private static calculatePercentile(value: number, distribution: number[]): number {
|
private static calculatePercentile(value: number, distribution: number[]): number {
|
||||||
if (distribution.length <= 1) return 1;
|
if (distribution.length <= 1) return 1;
|
||||||
const sorted = [...distribution].sort((a, b) => a - b);
|
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 {
|
private static calculateMedian(values: number[]): number {
|
||||||
|
|||||||
Reference in new issue
Block a user