diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 1a481f4..91a7a95 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -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(); } } diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts new file mode 100644 index 0000000..1c91ea2 --- /dev/null +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -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: "⚠️" }; + } +} diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 12e1e21..06ef9af 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -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 { diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 62ef9ec..8bb702d 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -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, - } + }, }; }); diff --git a/task.md b/task.md index fb7f33b..7a85138 100644 --- a/task.md +++ b/task.md @@ -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.