feat: Implement AI copilot product analysis logic and OpenRouter integration.

This commit is contained in:
Michael committed 2026-02-22 18:49:09 +01:00
1 parent 90c38bca8b
commit b92ed905d7
3 files changed
+31 -15

No files matched your search

@@ -12,12 +12,13 @@ En analysant les données de ventes fournies, génère une recommandation de gam
Critères de pondération et Règles Métier Strictes : Critères de pondération et Règles Métier Strictes :
1. Normalisation Totale (Base 2 magasins) : TOUTES les statistiques fournies sont PONDÉRÉES par 2 si le produit n'est au catalogue que de 1 magasin. 1. Normalisation Totale (Base 2 magasins) : TOUTES les statistiques fournies sont PONDÉRÉES par 2 si le produit n'est au catalogue que de 1 magasin.
2. Segmentation par Rayon (Univers) : Privilégie la comparaison "Intra-Rayon". Un produit doit être performant par rapport aux standards de son propre Rayon (Niveau 2). 2. Segmentation par Rayon (Univers) : Privilégie la comparaison "Intra-Rayon". Un produit doit être performant par rapport aux standards de son propre Rayon (Niveau 2).
3. Score de Performance Global (0-10) : Un score > 7 est un indicateur fort pour "Permanent" (A). Un score < 3 est un indicateur fort pour "Sortie" (Z). 3. Score de Performance Global (0-100) : Un score > 70 est un indicateur fort pour "Permanent" (A). Un score < 30 est un indicateur fort pour "Sortie" (Z).
4. Régularité des Ventes (0-12 mois) : Un produit avec une vente régulière (> 8 mois/an) est un candidat idéal pour "Permanent" (A). Une régularité faible (< 3 mois) couplée à un volume total faible indique une "Sortie" (Z). 4. Régularité des Ventes (0-12 mois) : Un produit avec une vente régulière (> 8 mois/an) est un candidat idéal pour "Permanent" (A). Une régularité faible (< 3 mois) couplée à un volume total faible indique une "Sortie" (Z).
5. Équilibre : Un produit "A" doit justifier sa place par son flux, sa rentabilité brute OU son score global. 5. Équilibre : Un produit "A" doit justifier sa place par son flux, sa rentabilité brute OU son score global.
Ta réponse doit être courte, directe et sans complaisance. Ta réponse doit être courte, directe et sans complaisance.
Format impératif : "[Recommandation]: [Justification courte]"`; Format impératif : "[Recommandation] : [Texte brut de l'explication sans aucun préfixe du type 'Justification:' ou 'Pourquoi:']"
Exemple : "A : Volume de vente et score élevés justifiant le maintien en rayon."`;
} }
static generateUserMessage(p: ProductAnalysisInput): string { static generateUserMessage(p: ProductAnalysisInput): string {
@@ -39,7 +40,7 @@ Rayon : ${p.libelleNiveau2 || "Non classé"}
Gamme actuelle : ${p.codeGamme ?? "Non définie"} Gamme actuelle : ${p.codeGamme ?? "Non définie"}
${volumeInfo}${benchmarks} ${volumeInfo}${benchmarks}
Indicateurs de Performance : Indicateurs de Performance :
- Score Global App : ${p.score.toFixed(1)}/10 - Score Global App : ${p.score.toFixed(1)}/100
- Régularité des ventes : ${p.regularityScore}/12 mois - Régularité des ventes : ${p.regularityScore}/12 mois
- Marge brute : ${p.tauxMarge.toFixed(1)}% - Marge brute : ${p.tauxMarge.toFixed(1)}%
- Historique mensuel (Pondéré) : ${monthlySummary} - Historique mensuel (Pondéré) : ${monthlySummary}
@@ -49,11 +50,26 @@ Quelle est ta recommandation (A, C ou Z) et pourquoi ?`;
static extractRecommendation(content: string): "A" | "C" | "Z" | null { 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) // Recherche une lettre A, C ou Z isolée (entourée de non-lettres ou début/fin)
// On favorise le format [A] ou "A:" mais on accepte A seul.
const match = content.match(/\b([ACZ])\b/i); const match = content.match(/\b([ACZ])\b/i);
if (match) { if (match) {
return match[1].toUpperCase() as "A" | "C" | "Z"; return match[1].toUpperCase() as "A" | "C" | "Z";
} }
return null; 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, "");
return cleaned.trim();
}
} }
@@ -44,7 +44,7 @@ export class OpenRouterClient {
const content = data.choices?.[0]?.message?.content ?? ""; const content = data.choices?.[0]?.message?.content ?? "";
return { return {
insight: content, insight: AnalysisEngine.cleanInsight(content),
codein: p.codein, codein: p.codein,
recommandation: AnalysisEngine.extractRecommendation(content) recommandation: AnalysisEngine.extractRecommendation(content)
}; };
@@ -190,22 +190,22 @@ export function BulkAiAnalyzer() {
if (isAnalyzing) { if (isAnalyzing) {
return ( return (
<div className="flex items-center gap-4 h-10 px-4 rounded-xl bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800"> <div className="flex items-center gap-4 h-10 px-4 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] shadow-sm">
<Loader2 className="w-4 h-4 text-indigo-500 animate-spin" /> <Loader2 className="w-4 h-4 text-[var(--accent)] animate-spin" />
<div className="flex flex-col min-w-[150px]"> <div className="flex flex-col min-w-[150px]">
<span className="text-[10px] font-bold text-indigo-700 dark:text-indigo-400 leading-none"> <span className="text-[10px] font-bold text-[var(--accent)] leading-none">
{progress.message} {progress.message}
</span> </span>
<div className="w-full bg-indigo-200 dark:bg-indigo-800/50 rounded-full h-1 mt-1"> <div className="w-full bg-[var(--bg-elevated)] rounded-full h-1 mt-1 overflow-hidden">
<div <div
className="bg-indigo-500 h-1 rounded-full transition-all duration-300" className="bg-[var(--accent)] h-1 rounded-full transition-all duration-300"
style={{ width: `${Math.max(5, (progress.current / progress.total) * 100)}%` }} style={{ width: `${Math.max(5, (progress.current / progress.total) * 100)}%` }}
></div> ></div>
</div> </div>
</div> </div>
<button <button
onClick={handleStop} onClick={handleStop}
className="p-1 hover:bg-indigo-100 dark:hover:bg-indigo-800 rounded-lg transition-colors text-indigo-600 dark:text-indigo-400" className="p-1 hover:bg-[var(--bg-elevated)] rounded-lg transition-colors text-[var(--text-muted)] hover:text-[var(--accent-error)]"
title="Arrêter l'analyse" title="Arrêter l'analyse"
> >
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
@@ -216,9 +216,9 @@ export function BulkAiAnalyzer() {
if (progress.current > 0 && !isAnalyzing) { if (progress.current > 0 && !isAnalyzing) {
return ( return (
<div className="flex items-center gap-2 h-10 px-4 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 cursor-default"> <div className="flex items-center gap-2 h-10 px-4 rounded-xl bg-[var(--accent-success-bg)] border border-[var(--accent-success)] shadow-sm cursor-default">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-500" /> <CheckCircle2 className="w-4 h-4 text-[var(--accent-success)]" />
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400"> <span className="text-xs font-bold text-[var(--accent-success)]">
{progress.errors > 0 {progress.errors > 0
? `Terminé (${progress.current - progress.errors}/${progress.total})` ? `Terminé (${progress.current - progress.errors}/${progress.total})`
: `Analyse Terminée` : `Analyse Terminée`
@@ -231,7 +231,7 @@ export function BulkAiAnalyzer() {
return ( return (
<button <button
onClick={handleAnalyze} onClick={handleAnalyze}
className="group flex items-center justify-center gap-2 h-10 px-6 rounded-xl text-sm font-bold transition-all shadow-sm bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:shadow-md hover:brightness-110 active:scale-95 border border-indigo-400" className="group flex items-center justify-center gap-2 h-10 px-6 rounded-xl text-sm font-black transition-all shadow-md bg-[var(--accent)] text-white hover:brightness-110 active:scale-95 border border-white/10"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
Analyse IA Analyse IA