mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: Implement AI Copilot context profiling to generate adaptive product analysis profiles and signals.
This commit is contained in:
1 parent
c3f511b612
commit
324a2621c1
5 files changed
+478
-48
No files matched your search
@@ -1,27 +1,128 @@
|
||||
import { ProductAnalysisInput } from "../models/ai-analysis.types";
|
||||
/**
|
||||
* CollectFlow — Analysis Engine (v3 — Prompt Contextuel Multi-Dimensionnel)
|
||||
*
|
||||
* Mary ne reçoit plus le verdict algorithmique pré-mâché. Elle reçoit une
|
||||
* fiche de contexte normalisée (ProductContextProfile) qui lui permet de
|
||||
* raisonner de façon autonome et cohérente sur la vraie valeur d'un produit.
|
||||
*/
|
||||
|
||||
import type { ProductAnalysisInput } from "../models/ai-analysis.types";
|
||||
import type { ProductContextProfile } from "./context-profiler";
|
||||
|
||||
export class AnalysisEngine {
|
||||
// -----------------------------------------------------------------------
|
||||
// SYSTEM PROMPT
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static generateSystemPrompt(): string {
|
||||
return `Tu es Mary, experte Senior en Stratégie Retail algorithmique. Ta mission est d'expliquer le verdict pour un produit.
|
||||
return `Tu es Mary, Senior Retail Strategist. Tu analyses des produits pour recommander A (garder), C (saisonnier), ou Z (sortir).
|
||||
|
||||
--- RÈGLES DE DÉCISION ---
|
||||
1. RÈGLE MÉTIER SPÉCIFIQUE (Priorité Absolue) : Si le manager a défini une règle et que LE PRODUIT ACTUEL correspond exactement à la condition de cette règle, le score et l'algorithme sont annulés. Tu dois suivre la recommandation de la règle et justifier par "Selon vos consignes...".
|
||||
2. ALGORITHME (Par défaut) : Si aucune règle métier n'est fournie OU si le produit actuel n'est pas ciblé par la règle, base-toi sur le PMV, la Marge, le CA et les Quantités. Le score (0 à 100) est une mesure relative (100 = Excellence, <30 = Faible).
|
||||
--- PHILOSOPHIE ---
|
||||
Un produit avec un score faible peut être VITAL s'il génère du trafic ou des marges.
|
||||
Un produit "moyen" peut être un pilier discret de son rayon.
|
||||
Ne jamais classer Z sans vérifier sa contribution réelle au chiffre d'affaires et aux volumes.
|
||||
|
||||
--- Interdictions ---
|
||||
Ne mentionne jamais de mois, de tendances ou de prédictions. Sois très concise (2 phrases max).
|
||||
--- ORDRE DE PRIORITÉ ---
|
||||
1. RÈGLE ABSOLUE : Score critique (< 20 sur 100) + aucun signal positif → TOUJOURS Z. Pas de discussion.
|
||||
2. GARDE-FOU : Si isProtected = true (Nouveauté / Dernier Produit / Top30) → TOUJOURS A. Priorité absolue.
|
||||
3. RÈGLE MANAGER : Si le manager a défini une consigne ET que le produit est concerné → Appliquer la consigne (rule_applies = true).
|
||||
4. ANALYSE CONTEXTUELLE : Utiliser la fiche de positionnement (percentiles, poids, signaux) pour raisonner.
|
||||
|
||||
--- GUIDE D'ANALYSE CONTEXTUELLE ---
|
||||
• Quadrant STAR ⭐ : Volume ET Marge > médiane → A sauf cas exceptionnel
|
||||
• Quadrant TRAFIC 🚶 : Fort volume, marge faible → Rôle de locomotive → A, justifier le rôle de trafic
|
||||
• Quadrant MARGE 💎 : Volume faible, forte marge → Capital rentabilité → A, justifier la contribution marge
|
||||
• Quadrant WATCH ⚠️ : Volume ET Marge < médiane → Analyser la santé (inactivité, poids CA, poids QTÉ)
|
||||
- Si poids CA ou poids QTÉ rayon > 5% → A ou C selon l'inactivité
|
||||
- Si poids faibles ET inactivité ≥ 2 mois → Z
|
||||
|
||||
--- COHÉRENCE INTER-PRODUITS (OBLIGATOIRE) ---
|
||||
Ne mets JAMAIS Z un produit dont le percentile CA et le percentile QTÉ sont tous les deux supérieurs à un autre produit déjà recommandé en A.
|
||||
|
||||
--- FORMAT DE RÉPONSE OBLIGATOIRE ---
|
||||
Tu dois UNIQUEMENT répondre avec un objet JSON valide, sans markdown, sans \`\`\`json.
|
||||
Structure attendue :
|
||||
Réponds UNIQUEMENT avec un JSON valide, sans markdown, sans \`\`\`json.
|
||||
{
|
||||
"rule_applies": boolean, // true si le produit est ciblé par la règle métier fournie, sinon false
|
||||
"recommendation": "A" | "C" | "Z", // La lettre finale retenue
|
||||
"justification": "Texte court expliquant le choix."
|
||||
"rule_applies": boolean,
|
||||
"recommendation": "A" | "C" | "Z",
|
||||
"justification": "2 phrases max. Cite les données clés : percentile, poids, quadrant, signal."
|
||||
}`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// USER MESSAGE — avec la fiche contextuelle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static generateUserMessage(p: ProductAnalysisInput): string {
|
||||
// Utilise la fiche ContextProfile si disponible, sinon fallback sur le mode legacy
|
||||
if (p.contextProfile) {
|
||||
return AnalysisEngine.buildContextualMessage(p, p.contextProfile);
|
||||
}
|
||||
return AnalysisEngine.buildLegacyMessage(p);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Message contextuel enrichi (nouveau mode MPC)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static buildContextualMessage(
|
||||
p: ProductAnalysisInput,
|
||||
ctx: ProductContextProfile
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// --- En-tête produit ---
|
||||
lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`);
|
||||
lines.push(`CATÉGORIE : ${ctx.libelleNiveau2} (${ctx.rayonSize} produits dans ce rayon sur ${ctx.lotSize} au total)`);
|
||||
lines.push("");
|
||||
|
||||
// --- Quadrant ---
|
||||
lines.push(`--- PROFIL QUADRANT ---`);
|
||||
lines.push(`${ctx.quadrantEmoji} ${ctx.quadrantLabel}`);
|
||||
lines.push(`Santé : ${ctx.regularityScore}/12 mois actifs | ${ctx.inactivityMonths} mois sans vente | Marge : ${ctx.tauxMarge.toFixed(1)}%`);
|
||||
lines.push("");
|
||||
|
||||
// --- Positionnement dans le lot ---
|
||||
lines.push(`--- POSITION DANS LE LOT FOURNISSEUR (${ctx.lotSize} produits) ---`);
|
||||
lines.push(`• CA : ${ctx.percentileCa}e percentile | Poids fournisseur : ${ctx.weightCaFournisseur}% | Poids rayon : ${ctx.weightCaRayon}%`);
|
||||
lines.push(`• Quantité : ${ctx.percentileQty}e percentile | Poids fournisseur : ${ctx.weightQtyFournisseur}% | Poids rayon : ${ctx.weightQtyRayon}%`);
|
||||
lines.push(`• Marge : ${ctx.percentileMarge}e percentile`);
|
||||
lines.push(`• Score composite : ${ctx.percentileComposite}/100`);
|
||||
lines.push("");
|
||||
|
||||
// --- Signaux ---
|
||||
lines.push(`--- SIGNAUX ---`);
|
||||
lines.push(`${ctx.isTop20Ca ? "[✓]" : "[ ]"} Top 20% CA fournisseur`);
|
||||
lines.push(`${ctx.isTop20Qty ? "[✓]" : "[ ]"} Top 20% Quantités fournisseur`);
|
||||
lines.push(`${ctx.isHighVolumeWithLowMargin ? "[✓]" : "[ ]"} Signal Trafic : fort volume ET marge < P40 du lot → rôle de locomotive`);
|
||||
lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Signal Marge : marge > P70 du lot → capital rentabilité`);
|
||||
lines.push(`${ctx.isAboveMedianComposite ? "[✓]" : "[ ]"} Au-dessus de la médiane composite`);
|
||||
lines.push(`${ctx.scoreCritique ? "[✗ CRITIQUE]" : "[ ]"} Score brut critique (< 20) — candidat Z direct si aucun signal positif`);
|
||||
lines.push("");
|
||||
|
||||
// --- Protection ---
|
||||
if (ctx.isProtected) {
|
||||
lines.push(`🛡️ GARDE-FOU ACTIF : ${ctx.protectionReason} → Recommandation A obligatoire`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// --- Règles manager ---
|
||||
if (p.supplierContext) {
|
||||
lines.push(`--- RÈGLE MANAGER ---`);
|
||||
lines.push(`"${p.supplierContext}"`);
|
||||
lines.push(`→ Évalue si ce produit ("${ctx.libelle1}") est concerné par cette consigne.`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`Génère UNIQUEMENT le JSON :`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fallback legacy (si contextProfile absent — compatibilité ascendante)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static buildLegacyMessage(p: ProductAnalysisInput): string {
|
||||
const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0;
|
||||
|
||||
let contextStats = "";
|
||||
@@ -31,20 +132,22 @@ Structure attendue :
|
||||
- Poids Secteur/Rayon (Quantité) : ${p.shareQty.toFixed(1)}% des ventes du rayon`;
|
||||
}
|
||||
|
||||
const scoringInfo = p.scoring ? `
|
||||
--- RÉSULTATS DÉCISION RAYON ---
|
||||
const scoringInfo = p.scoring
|
||||
? `\n--- RÉSULTATS DÉCISION RAYON ---
|
||||
INDICE RAYON: ${p.scoring.compositeScore}/100 (Seuil 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"}
|
||||
` : "";
|
||||
`
|
||||
: "";
|
||||
|
||||
const contextRules = p.supplierContext ? `
|
||||
--- RÈGLES MÉTIER SPÉCIFIQUES ---
|
||||
Le manager a défini cette consigne pour ce fournisseur :
|
||||
const contextRules = p.supplierContext
|
||||
? `\n--- RÈGLES MÉTIER SPÉCIFIQUES ---
|
||||
Le manager a défini cette consigne pour ce fournisseur :
|
||||
"${p.supplierContext}"
|
||||
|
||||
Attention : Évalue d'abord si le produit ("${p.libelle1}") est concerné par cette consigne. Si oui, \`rule_applies\` doit être \`true\`. Sinon, \`false\`.
|
||||
` : "";
|
||||
`
|
||||
: "";
|
||||
|
||||
return `PRODUIT : ${p.libelle1} (${p.codein})
|
||||
Famille / Rayon : ${p.libelleNiveau2}
|
||||
@@ -55,8 +158,11 @@ Verdict purement algorithmique : ${p.scoring?.decision || "Non calculé"}${conte
|
||||
Génère UNIQUEMENT le JSON :`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Utilitaires de parsing (inchangés)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static extractRecommendation(content: string): "A" | "C" | "Z" | null {
|
||||
// Recherche une lettre A, C ou Z isolée (entourée de non-lettres ou début/fin)
|
||||
const match = content.match(/\b([ACZ])\b/i);
|
||||
if (match) {
|
||||
return match[1].toUpperCase() as "A" | "C" | "Z";
|
||||
@@ -64,19 +170,13 @@ Génère UNIQUEMENT le JSON :`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le texte de l'IA pour ne garder que la justification pure.
|
||||
* Supprime les préfixes comme "[A] :", "Justification :", "Justification courte :", etc.
|
||||
*/
|
||||
static cleanInsight(content: string): string {
|
||||
let cleaned = content;
|
||||
|
||||
// Supprime le préfixe de recommandation type "A : ", "[A] - ", "A -" au début
|
||||
cleaned = cleaned.replace(/^\[?[ACZ]\]?\s*[:\s-]+\s*/i, "");
|
||||
|
||||
// Supprime les préfixes de justification connus (insensible à la casse, gère variations)
|
||||
cleaned = cleaned.replace(/^(justification|explication|pourquoi|justification courte|raison|avis)\s*[:\s-]+\s*/i, "");
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
/^(justification|explication|pourquoi|justification courte|raison|avis)\s*[:\s-]+\s*/i,
|
||||
""
|
||||
);
|
||||
return cleaned.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* CollectFlow — Context Profiler
|
||||
*
|
||||
* Génère une fiche de contexte normalisée et adaptative pour chaque produit
|
||||
* AVANT de le soumettre à l'IA. Ce profiler se base sur la distribution RÉELLE
|
||||
* du lot (fournisseur × rayon) et ne contient aucune règle fixe de seuil.
|
||||
*
|
||||
* L'objectif est de donner à Mary un contexte statistique riche qui lui permet
|
||||
* de raisonner de manière autonome et cohérente, y compris pour les produits
|
||||
* à fort volume/faible marge (Générateurs de Trafic) ou à faible volume/forte
|
||||
* marge (Contributeurs de Marge).
|
||||
*/
|
||||
|
||||
import type { ProductAnalysisInput } from "../models/ai-analysis.types";
|
||||
import type { ScoringResult } from "./scoring-engine";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH";
|
||||
|
||||
export interface ProductContextProfile {
|
||||
// Identité
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
libelleNiveau2: string;
|
||||
|
||||
// Profil Quadrant (issu de la position Quantité vs Marge dans le lot)
|
||||
quadrant: Quadrant;
|
||||
quadrantLabel: string;
|
||||
quadrantEmoji: string;
|
||||
|
||||
// Percentiles dans le lot fournisseur (0 = plus faible, 100 = meilleur)
|
||||
percentileCa: number;
|
||||
percentileQty: number;
|
||||
percentileMarge: number;
|
||||
percentileComposite: number;
|
||||
|
||||
// Poids réels dans le lot fournisseur
|
||||
weightCaFournisseur: number; // % du CA total fournisseur
|
||||
weightQtyFournisseur: number; // % des QTÉ totales fournisseur
|
||||
|
||||
// Poids réels dans le rayon (N2)
|
||||
weightCaRayon: number; // % du CA de sa catégorie N2
|
||||
weightQtyRayon: number; // % des QTÉ de sa catégorie N2
|
||||
|
||||
// Santé temporelle
|
||||
tauxMarge: number;
|
||||
inactivityMonths: number;
|
||||
regularityScore: number; // Nombre de mois actifs sur 12
|
||||
|
||||
// Contexte du lot
|
||||
lotSize: number;
|
||||
rayonSize: number;
|
||||
|
||||
// Signaux booléens (calculés sans seuil fixe — relatifs à la distribution)
|
||||
isAboveMedianComposite: boolean;
|
||||
isTop20Ca: boolean; // Top 20% CA du fournisseur
|
||||
isTop20Qty: boolean; // Top 20% QTÉ du fournisseur
|
||||
isHighVolumeWithLowMargin: boolean; // Vol > P60 ET marge < P40 → Trafic
|
||||
isMargePure: boolean; // Marge > P70 MÊME si vol < médiane → Marge
|
||||
|
||||
// Gardes-fous (issus du ScoringEngine)
|
||||
isProtected: boolean;
|
||||
protectionReason: string;
|
||||
|
||||
// Règle absolue (seule règle fixe du système)
|
||||
scoreCritique: boolean; // score brut < 20 = candidat Z direct
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers statistiques (purs, sans effet de bord)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Calcule le percentile d'une valeur dans une distribution (0 à 100). */
|
||||
function computePercentile(value: number, distribution: number[]): number {
|
||||
if (distribution.length <= 1) return 100;
|
||||
const sorted = [...distribution].sort((a, b) => a - b);
|
||||
const first = sorted.indexOf(value);
|
||||
const last = sorted.lastIndexOf(value);
|
||||
if (first === -1) return 0;
|
||||
const avgRank = (first + last) / 2;
|
||||
return Math.round((avgRank / (sorted.length - 1)) * 100);
|
||||
}
|
||||
|
||||
/** Calcule la médiane d'une liste de nombres. */
|
||||
function computeMedian(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;
|
||||
}
|
||||
|
||||
/** Retourne la valeur au Pième percentile d'une distribution. */
|
||||
function valueAtPercentile(values: number[], p: number): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
|
||||
return sorted[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la clé de groupement pour le rayon de niveau 2.
|
||||
*
|
||||
* Règles de priorité :
|
||||
* 1. Utilise `codeNomenclatureN2` si disponible (4 premiers chiffres du code à 6 chiffres).
|
||||
* 2. Sinon, tente d'extraire les 4 premiers chiffres de `libelleNiveau2`
|
||||
* (fallback si les codes ne sont pas transmis).
|
||||
* 3. Sinon, retourne la valeur brute de `libelleNiveau2` pour ne pas perdre le groupement.
|
||||
*
|
||||
* Cela évite les "faux petits rayons" (groupement trop fin sur N3).
|
||||
*/
|
||||
function getRayonKey(p: ProductAnalysisInput): string {
|
||||
if (p.codeNomenclatureN2) {
|
||||
return p.codeNomenclatureN2;
|
||||
}
|
||||
// Fallback : si libelleNiveau2 commence par 4 chiffres, les extraire
|
||||
const numericPrefix = p.libelleNiveau2?.match(/^(\d{4})/);
|
||||
if (numericPrefix) {
|
||||
return numericPrefix[1];
|
||||
}
|
||||
return p.libelleNiveau2 ?? "default";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profiler principal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ContextProfiler {
|
||||
/**
|
||||
* Génère le profil contextuel d'un produit au sein de son lot fournisseur.
|
||||
*
|
||||
* @param target - Le produit à profiler
|
||||
* @param allProds - Tous les produits du fournisseur (lot complet)
|
||||
* @param scoring - Résultat du ScoringEngine pour ce produit
|
||||
*/
|
||||
static buildProfile(
|
||||
target: ProductAnalysisInput,
|
||||
allProds: ProductAnalysisInput[],
|
||||
scoring: ScoringResult
|
||||
): ProductContextProfile {
|
||||
if (allProds.length === 0) {
|
||||
throw new Error("[ContextProfiler] Le lot de produits est vide.");
|
||||
}
|
||||
|
||||
// --- 1. Totaux fournisseur ---
|
||||
const totalCaFournisseur = allProds.reduce((s, p) => s + (p.totalCa ?? 0), 0);
|
||||
const totalQtyFournisseur = allProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0);
|
||||
|
||||
// --- 2. Totaux du rayon (Niveau 2 de nomenclature = 4 premiers chiffres) ---
|
||||
// On groupe sur le code N2 pour éviter les "faux petits rayons" issus d'un
|
||||
// groupement trop fin sur le niveau 3 (code à 6 chiffres).
|
||||
const targetRayonKey = getRayonKey(target);
|
||||
const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey);
|
||||
const totalCaRayon = rayonProds.reduce((s, p) => s + (p.totalCa ?? 0), 0);
|
||||
const totalQtyRayon = rayonProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0);
|
||||
|
||||
// --- 3. Percentiles dans le lot fournisseur ---
|
||||
const allCaValues = allProds.map(p => p.totalCa ?? 0);
|
||||
const allQtyValues = allProds.map(p => p.totalQuantite ?? 0);
|
||||
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
|
||||
|
||||
const pCa = computePercentile(target.totalCa ?? 0, allCaValues);
|
||||
const pQty = computePercentile(target.totalQuantite ?? 0, allQtyValues);
|
||||
const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues);
|
||||
const pComposite = scoring.compositeScore; // Déjà 0-100
|
||||
|
||||
// --- 4. Tops (seuils adaptatifs sur la distribution réelle) ---
|
||||
const top20CaThreshold = valueAtPercentile(allCaValues, 80);
|
||||
const top20QtyThreshold = valueAtPercentile(allQtyValues, 80);
|
||||
const isTop20Ca = (target.totalCa ?? 0) >= top20CaThreshold;
|
||||
const isTop20Qty = (target.totalQuantite ?? 0) >= top20QtyThreshold;
|
||||
|
||||
// --- 5. Médiane du lot (pour les signaux) ---
|
||||
const medianComposite = computeMedian(
|
||||
allProds.map((_, i) => i) // Approximation : le ScoringEngine ne donne pas tous les composites
|
||||
);
|
||||
// On utilise directement le percentile composite pour isAboveMedian
|
||||
const isAboveMedianComposite = pComposite >= 50;
|
||||
|
||||
// --- 6. Signaux Trafic / Marge (seuils P40/P60/P70 sur la distribution) ---
|
||||
const qty60 = valueAtPercentile(allQtyValues, 60);
|
||||
const qty40 = valueAtPercentile(allQtyValues, 40);
|
||||
const marge40 = valueAtPercentile(allMargeValues, 40);
|
||||
const marge70 = valueAtPercentile(allMargeValues, 70);
|
||||
const medianQty = computeMedian(allQtyValues);
|
||||
|
||||
const isHighVolumeWithLowMargin =
|
||||
(target.totalQuantite ?? 0) >= qty60 &&
|
||||
(target.tauxMarge ?? 0) < marge40;
|
||||
|
||||
const isMargePure =
|
||||
(target.tauxMarge ?? 0) >= marge70 &&
|
||||
(target.totalQuantite ?? 0) < medianQty;
|
||||
|
||||
// --- 7. Quadrant (basé sur médiane QTÉ et médiane Marge du lot) ---
|
||||
const medianMarge = computeMedian(allMargeValues);
|
||||
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
|
||||
target.totalQuantite ?? 0,
|
||||
target.tauxMarge ?? 0,
|
||||
medianQty,
|
||||
medianMarge
|
||||
);
|
||||
|
||||
// --- 8. Gardes-fous (issus du ScoringEngine) ---
|
||||
const isProtected =
|
||||
scoring.decision.isRecent ||
|
||||
scoring.decision.isTop30Supplier ||
|
||||
scoring.decision.isLastProduct;
|
||||
|
||||
let protectionReason = "";
|
||||
if (scoring.decision.isRecent) protectionReason = "Nouveauté (< 3 mois de données)";
|
||||
else if (scoring.decision.isTop30Supplier) protectionReason = "Top 30% CA Fournisseur";
|
||||
else if (scoring.decision.isLastProduct) protectionReason = "Dernière référence du fournisseur";
|
||||
|
||||
// --- 9. Règle absolue : score brut < 20 ---
|
||||
const scoreCritique = (target.score ?? 0) < 20;
|
||||
|
||||
return {
|
||||
codein: target.codein,
|
||||
libelle1: target.libelle1,
|
||||
libelleNiveau2: target.libelleNiveau2 ?? "Général",
|
||||
|
||||
quadrant,
|
||||
quadrantLabel,
|
||||
quadrantEmoji,
|
||||
|
||||
percentileCa: pCa,
|
||||
percentileQty: pQty,
|
||||
percentileMarge: pMarge,
|
||||
percentileComposite: pComposite,
|
||||
|
||||
weightCaFournisseur:
|
||||
totalCaFournisseur > 0
|
||||
? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10
|
||||
: 0,
|
||||
weightQtyFournisseur:
|
||||
totalQtyFournisseur > 0
|
||||
? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10
|
||||
: 0,
|
||||
weightCaRayon:
|
||||
totalCaRayon > 0
|
||||
? Math.round(((target.totalCa ?? 0) / totalCaRayon) * 1000) / 10
|
||||
: 0,
|
||||
weightQtyRayon:
|
||||
totalQtyRayon > 0
|
||||
? Math.round(((target.totalQuantite ?? 0) / totalQtyRayon) * 1000) / 10
|
||||
: 0,
|
||||
|
||||
tauxMarge: target.tauxMarge ?? 0,
|
||||
inactivityMonths: target.inactivityMonths ?? 0,
|
||||
regularityScore: target.regularityScore ?? 0,
|
||||
|
||||
lotSize: allProds.length,
|
||||
rayonSize: rayonProds.length,
|
||||
|
||||
isAboveMedianComposite,
|
||||
isTop20Ca,
|
||||
isTop20Qty,
|
||||
isHighVolumeWithLowMargin,
|
||||
isMargePure,
|
||||
|
||||
isProtected,
|
||||
protectionReason,
|
||||
|
||||
scoreCritique,
|
||||
};
|
||||
}
|
||||
|
||||
/** Résout le quadrant en fonction des médianes du lot. */
|
||||
private static resolveQuadrant(
|
||||
qty: number,
|
||||
marge: number,
|
||||
medianQty: number,
|
||||
medianMarge: number
|
||||
): { quadrant: Quadrant; quadrantLabel: string; quadrantEmoji: string } {
|
||||
if (qty > medianQty && marge > medianMarge) {
|
||||
return { quadrant: "STAR", quadrantLabel: "Star (Vol élevé, Marge élevée)", quadrantEmoji: "⭐" };
|
||||
}
|
||||
if (qty > medianQty && marge <= medianMarge) {
|
||||
return { quadrant: "TRAFIC", quadrantLabel: "Générateur de Trafic (Vol élevé, Marge faible)", quadrantEmoji: "🚶" };
|
||||
}
|
||||
if (qty <= medianQty && marge > medianMarge) {
|
||||
return { quadrant: "MARGE", quadrantLabel: "Contributeur de Marge (Vol faible, Marge élevée)", quadrantEmoji: "💎" };
|
||||
}
|
||||
return { quadrant: "WATCH", quadrantLabel: "Sous-performant (Vol faible, Marge faible)", quadrantEmoji: "⚠️" };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { ProductContextProfile } from "../business/context-profiler";
|
||||
|
||||
export interface ProductAnalysisInput {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
libelleNiveau2?: string;
|
||||
/**
|
||||
* Code de nomenclature au niveau 2 (4 premiers chiffres du code à 6 chiffres).
|
||||
* Utilisé pour calculer les poids rayon sur le bon périmètre (pas trop fin = niveau 3).
|
||||
* Correspond à `code2` dans ProductRow.
|
||||
*/
|
||||
codeNomenclatureN2?: string;
|
||||
totalCa: number;
|
||||
tauxMarge: number;
|
||||
totalQuantite: number;
|
||||
@@ -43,6 +51,12 @@ export interface ProductAnalysisInput {
|
||||
};
|
||||
/** Optional context rules for the supplier */
|
||||
supplierContext?: string;
|
||||
|
||||
/**
|
||||
* Fiche de contexte enrichie générée par le ContextProfiler.
|
||||
* Transmise au prompt de l'IA pour une analyse multi-dimensionnelle.
|
||||
*/
|
||||
contextProfile?: ProductContextProfile;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useRef } from "react";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
||||
import { ScoringEngine } from "@/features/ai-copilot/business/scoring-engine";
|
||||
import { ContextProfiler } from "@/features/ai-copilot/business/context-profiler";
|
||||
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
|
||||
@@ -103,6 +104,9 @@ export function BulkAiAnalyzer() {
|
||||
codein: r.codein,
|
||||
libelle1: r.libelle1 || "",
|
||||
libelleNiveau2: r.libelleNiveau2 || "Général",
|
||||
// Code nomenclature niveau 2 (4 premiers chiffres) — utilisé par le ContextProfiler
|
||||
// pour calculer le poids rayon sur un périmètre cohérent (pas trop fin = niveau 3).
|
||||
codeNomenclatureN2: r.code2 || undefined,
|
||||
totalCa: r.totalCa || 0,
|
||||
tauxMarge: r.tauxMarge || 0,
|
||||
totalQuantite: r.totalQuantite || 0,
|
||||
@@ -114,7 +118,7 @@ export function BulkAiAnalyzer() {
|
||||
avgQtyRayon1: rb ? rb.avg1 : avgQty1,
|
||||
avgQtyRayon2: rb ? rb.avg2 : avgQty2,
|
||||
storeCount: sc,
|
||||
sales12m: Object.fromEntries(Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight])),
|
||||
sales12m: Object.fromEntries(Object.entries(r.sales12m || {}).map(([month, val]) => [month, (val as number) * weight])),
|
||||
codeGamme: r.codeGamme || null,
|
||||
score: r.score || 0,
|
||||
regularityScore: regScore,
|
||||
@@ -124,13 +128,32 @@ export function BulkAiAnalyzer() {
|
||||
inactivityMonths: inactivity,
|
||||
supplierContext: supplierContext
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// 2. Calculer le scoring algorithmique pour chaque produit
|
||||
const scoringResults = new Map(
|
||||
initialPayloads.map(p => [p.codein, ScoringEngine.analyzeRayon(p, initialPayloads)])
|
||||
);
|
||||
|
||||
// 3. Générer la fiche de contexte enrichie (ContextProfiler v3)
|
||||
// On transmet TOUS les produits du lot pour que les percentiles et poids
|
||||
// soient calculés sur la distribution réelle (fournisseur complet).
|
||||
const productPayloads = initialPayloads.map(p => {
|
||||
const scoringRes = ScoringEngine.analyzeRayon(p, initialPayloads);
|
||||
const scoringRes = scoringResults.get(p.codein)!;
|
||||
|
||||
// Fiche contextuelle (poids CA/QTÉ fournisseur + rayon, percentiles, signaux)
|
||||
let contextProfile: ProductAnalysisInput["contextProfile"];
|
||||
try {
|
||||
contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes);
|
||||
} catch (err) {
|
||||
console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err);
|
||||
contextProfile = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
contextProfile,
|
||||
scoring: {
|
||||
compositeScore: scoringRes.compositeScore,
|
||||
decision: scoringRes.decision.recommendation,
|
||||
@@ -139,7 +162,7 @@ export function BulkAiAnalyzer() {
|
||||
isRecent: scoringRes.decision.isRecent,
|
||||
isLastProduct: scoringRes.decision.isLastProduct,
|
||||
threshold: scoringRes.decision.threshold,
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
# Tâches en cours : Optimisation de l'Analyse IA (Phase 2 - Contextualisation)
|
||||
# Tâches en cours : Refonte du Moteur de Recommandation IA (Multi-Dimensionnel)
|
||||
|
||||
## Contexte
|
||||
|
||||
L'IA (Mary) donne des recommandations incohérentes (ex: [Z] pour un produit plus performant qu'un [A]) car elle manque de repères relatifs. Elle ne connaît pas le poids réel d'un produit dans le chiffre d'affaires total du fournisseur ou du rayon.
|
||||
Mary (IA) donnait des recommandations basées sur le score brut sans analyse contextuelle. La refonte introduit un profilage statistique adaptatif (poids CA, poids QTÉ, marge, profil quadrant) avant de soumettre à l'IA.
|
||||
|
||||
## Focus Actuel
|
||||
|
||||
**Recherche & Développement** : Mieux définir les critères de ranking pour Mary afin d'éliminer les faux négatifs (Score 70+ classé en Z).
|
||||
**Implémentation** : Création du `context-profiler.ts`, refonte du prompt `analysis-engine.ts`, injection dans `bulk-ai-analyzer.tsx`.
|
||||
|
||||
## Master Plan
|
||||
|
||||
- [x] Correction graphique du modal (Slate-950/Apple)
|
||||
- [x] Étape 20 : Intégration des poids (%) et contribution relative (Analyse de Cohérence)
|
||||
- [x] Étape 21 : Sanctuarisation des produits stratégiques dans le prompt
|
||||
- [x] Étape 22 : Test et validation des recommandations (Élimination des faux Z)
|
||||
- [ ] Recherche de meilleures pratiques pour le ranking IA (Analyse ABC/XYZ enrichie)
|
||||
- [ ] Implémentation du calcul des poids (%) dans `score-engine.ts`
|
||||
- [ ] Mise à jour des types `ProductAnalysisInput`
|
||||
- [ ] Enrichissement du prompt Mary avec les notions de contribution (%)
|
||||
- [ ] Test et validation sur les cas limites (Score 70+ classé en Z par erreur)
|
||||
- [x] Diagnostic et analyse du pipeline existant
|
||||
- [x] Rédaction du plan d'implémentation (validé par Michael)
|
||||
- [x] Créer `context-profiler.ts` (profilage adaptatif, percentiles dynamiques)
|
||||
- [x] Modifier `ai-analysis.types.ts` (ajout `contextProfile` + `codeNomenclatureN2`)
|
||||
- [x] Refondre `analysis-engine.ts` (nouveau prompt Mary v3)
|
||||
- [x] Modifier `bulk-ai-analyzer.tsx` (injection ContextProfiler + `r.code2`)
|
||||
- [/] Vérification TypeScript (`tsc --noEmit` en cours)
|
||||
- [ ] Tests manuels sur cas critiques (à faire par Michael)
|
||||
|
||||
## Progress Log
|
||||
|
||||
- **Phase 1 terminée** : Modal UI polie et Portail React opérationnel.
|
||||
- **Phase 1.5 terminée** : Mary prend désormais en compte le volume moyen du rayon et le PMV.
|
||||
- **Découverte** : Mary a besoin du "Poids CA" et du "Total Fournisseur" pour donner un avis juste. Un CA de 552€ est faible pour un géant, mais vital pour un petit artisan.
|
||||
- **Diagnostic** : Problème identifié — Mary ratifiait le verdict sans interpréter le contexte statistique.
|
||||
- **Plan** : Architecture MPC validée par Michael.
|
||||
- **context-profiler.ts** : Créé. Calcule percentiles, poids CA/QTÉ fournisseur ET rayon N2, signaux Trafic/Marge, quadrant — tout adaptatif sur la distribution réelle.
|
||||
- **Correction N2** : Michael a signalé que le poids rayon doit être au niveau 2 (4 premiers chiffres). Corrigé avec `getRayonKey()` qui utilise `codeNomenclatureN2` (= `r.code2`) au lieu de `libelleNiveau2` (N3 trop fin).
|
||||
- **analysis-engine.ts** : Refonte complète. Mary reçoit une fiche normalisée (percentiles, poids, signaux) au lieu d'un verdict pré-mâché.
|
||||
- **bulk-ai-analyzer.tsx** : Injection du ContextProfiler dans le pipeline. `r.code2` transmis pour le groupement N2.
|
||||
Reference in new issue
Block a user