feat: Implement AI Copilot context profiling to generate adaptive product analysis profiles and signals.

This commit is contained in:
Michael committed 2026-02-27 09:37:49 +01:00
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,
}
},
};
});
+17 -15
View File
@@ -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.