feat: implement AI analysis engine for product recommendation and justification by generating prompts and processing responses.

This commit is contained in:
Michael committed 2026-02-26 16:15:20 +01:00
1 parent e50335a8c2
commit ea732bafc4
5 files changed
+85 -66

No files matched your search

+52 -27
View File
@@ -273,47 +273,72 @@
box-shadow: 0 0 0 3px var(--accent-bg); box-shadow: 0 0 0 3px var(--accent-bg);
} }
/* ── Apple-style button ── */ /* ── Apple-style Action Buttons ── */
.apple-btn-primary { .btn-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 8px 18px; gap: 8px;
background: var(--brand-solid); height: 36px;
color: #fff; padding: 0 16px;
border-radius: 10px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
border-radius: 8px; transition: all 0.15s ease-out;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
border: none;
} }
.apple-btn-primary:hover { .btn-action:active {
opacity: 0.88; transform: scale(0.96);
} }
.apple-btn-primary:active { .btn-action:disabled {
transform: scale(0.97); opacity: 0.5;
pointer-events: none;
} }
.apple-btn-secondary { .btn-action-primary {
display: inline-flex; background: var(--brand-solid);
align-items: center; color: #fff;
gap: 6px; border: 1px solid rgba(0, 0, 0, 0.1);
padding: 7px 14px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
background: var(--bg-elevated); }
border: 1px solid var(--border-strong);
.btn-action-primary:hover {
filter: brightness(1.1);
}
.btn-action-secondary {
background: var(--bg-surface);
color: var(--text-primary); color: var(--text-primary);
font-size: 12px; border: 1px solid var(--border-strong);
font-weight: 500; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
} }
.apple-btn-secondary:hover { .btn-action-secondary:hover {
background: var(--bg-overlay); background: var(--bg-elevated);
}
.btn-action-ai {
background: var(--accent);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-action-ai:hover {
filter: brightness(1.1);
}
.btn-action-success {
background: var(--accent-success-bg);
color: var(--accent-success);
border: 1px solid var(--accent-success);
cursor: default;
}
.btn-action-success:active {
transform: none;
} }
/* ── Segment control (theme toggle) ── */ /* ── Segment control (theme toggle) ── */
@@ -2,27 +2,29 @@ import { ProductAnalysisInput } from "../models/ai-analysis.types";
export class AnalysisEngine { export class AnalysisEngine {
static generateSystemPrompt(): string { static generateSystemPrompt(): string {
return `Tu es Mary, experte Senior en Stratégie Retail. Ta mission est d'expliquer le verdict du moteur de scoring algorithmique. return `Tu es Mary, experte Senior en Stratégie Retail algorithmique. Ta mission est d'expliquer le verdict pour un produit.
Le score (0 à 100) est une mesure de performance RELATIVE au sein du rayon.
--- INTERPRÉTATION DU SCORE --- --- RÈGLE D'OR ABSOLUE ---
- 100/100 : Excellence absolue (Élite du rayon). Si des "RÈGLES MÉTIER SPÉCIFIQUES" sont fournies pour ce fournisseur, ELLES ÉCRASENT TOTALEMENT LE SCORE ET L'ALGORITHME.
Si le produit (ex: Agenda, Calendrier, Stylo) correspond à une règle du fournisseur t'indiquant de le placer en gamme A, C ou Z, TU DOIS IGNORER LE VERDICT ALGORITHMIQUE et appliquer la recommandation de la règle métier. Justifie alors UNIQUEMENT en citant la règle du manager.
--- INTERPRÉTATION ALGORITHMIQUE (Si aucune règle métier ne s'applique) ---
Le score (0 à 100) est une mesure de performance RELATIVE :
- 100/100 : Excellence.
- > 70/100 : Très bonne performance. - > 70/100 : Très bonne performance.
- 30-70/100 : Performance moyenne. - 30-70/100 : Performance moyenne.
- < 30/100 : Performance faible. - < 30/100 : Faible.
- 0/100 : Aucune performance enregistrée (ou dernier du classement).
--- TON RÔLE --- --- TON RÔLE ---
1. JUSTIFIER : Explique le score de manière purement factuelle, "ligne par ligne", en te basant UNIQUEMENT sur : le CA, la Quantité vendue, la Marge, le Poids Fournisseur (importance du produit pour ce fournisseur) et le Poids Rayon / Nomenclature (importance du produit dans le rayon). 1. JUSTIFIER : Explique la gamme choisie. Si c'est basé sur une Règle Métier, dis "Selon vos règles: [règle]". Sinon, base-toi sur le PMV, la Marge, le CA et les Quantités.
2. EXPLIQUER LE VERDICT [A] : Si le verdict est [A] malgré un score faible, c'est une PROTECTION MÉTIER (ex: Produit Récent, Leader Fournisseur, Dernier Produit). Explique cette protection avec bienveillance. 2. NE JAMAIS mentionner de mois, tendances ou prédictions temporelles.
3. EXPLIQUER LE VERDICT [Z] : Si le verdict est [Z] malgré de bons KPIs, c'est souvent dû à une RÈGLE D'EXCLUSION ABSOLUE (ex: Note Globale < 20). Justifie dans ce cas par l'insuffisance critique de la performance globale du produit. 3. CONCISE : 2 phrases maximum.
4. INTERDICTION : Ne mentionne JAMAIS de tendances mensuelles, historiques, prédictions ou notions de temps. Ne calcule pas de variations. Reste strictement concentrée sur la photographie des indicateurs globaux fournis.
5. CONCISE : 2 phrases maximum. Évite le jargon de data-scientist.
--- FORMAT ATTENDU --- --- FORMAT ATTENDU (La lettre doit être isolée au début) ---
"[Recommandation] : [Justification factuelle incluant le Score et les poids/axes clés]" "[Recommandation_Finale] : [Explication]"
Exemple : "[A] : Score de 85/100 porté par une forte marge (45%) et un poids important dans le rayon (15% des quantités)." Exemple 1 (Règle Métier) : "C : Selon vos consignes, les calendriers sont toujours classés en gamme C pour ce fournisseur, indépendamment des ventes."
Exemple : "[Z] : Rétrogradé car la note globale (15/100) est critique, malgré un volume de vente acceptable au sein du rayon."`; Exemple 2 (Algo) : "A : Score de 85/100 justifié par une très forte marge (45%) et une excellente contribution au CA."
Exemple 3 (Algo Z) : "Z : Rétrogradé malgré de bons volumes car le score global (15/100) est critique pour ce rayon."`;
} }
static generateUserMessage(p: ProductAnalysisInput): string { static generateUserMessage(p: ProductAnalysisInput): string {
@@ -44,15 +46,15 @@ Exemple : "[Z] : Rétrogradé car la note globale (15/100) est critique, malgré
const contextRules = p.supplierContext ? ` const contextRules = p.supplierContext ? `
--- RÈGLES MÉTIER SPÉCIFIQUES --- --- RÈGLES MÉTIER SPÉCIFIQUES ---
Le manager a défini ces règles absolues pour ce fournisseur (TU DOIS LES RESPECTER EN PRIORITÉ) : Le manager a défini ces règles absolues pour ce fournisseur. TU DOIS VÉRIFIER SI LE PRODUIT (Nom ou libellé : "${p.libelle1}") CORRESPOND À CES RÈGLES. SI OUI, TU APPLIQUES LA GAMME INDIQUÉE PAR LA RÈGLE :
"${p.supplierContext}" "${p.supplierContext}"
` : ""; ` : "";
return `PRODUIT: ${p.libelle1} (${p.codein}) return `PRODUIT: ${p.libelle1} (${p.codein})
PERFORMANCE GLOBALE: Score Global ${p.score.toFixed(1)}/100 PERFORMANCE GLOBALE: Score Global ${p.score.toFixed(1)}/100
KPIs: CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo}${contextRules} KPIs: CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo}
Verdict algorithmique: ${p.scoring?.decision || "Non calculé"} Verdict purement algorithmique: ${p.scoring?.decision || "Non calculé"}${contextRules}
Justifie ce verdict factuellement.`; Ta tâche : Donne la recommandation finale (A, C ou Z) et justifie-la factuellement. Priorise TOUJOURS les "Règles Métier Spécifiques" si elles s'appliquent à ce produit, sinon utilise le "Verdict purement algorithmique".`;
} }
static extractRecommendation(content: string): "A" | "C" | "Z" | null { static extractRecommendation(content: string): "A" | "C" | "Z" | null {
@@ -252,16 +252,16 @@ export function BulkAiAnalyzer() {
</button> </button>
</div> </div>
) : progress.current > 0 ? ( ) : progress.current > 0 ? (
<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"> <div className="btn-action btn-action-success">
<CheckCircle2 className="w-4 h-4 text-[var(--accent-success)]" /> <CheckCircle2 className="w-4 h-4" />
<span className="text-xs font-bold text-[var(--accent-success)]"> <span>
{progress.errors > 0 ? `Terminé (${progress.current - progress.errors}/${progress.total})` : `Analyse Terminée`} {progress.errors > 0 ? `Terminé (${progress.current - progress.errors}/${progress.total})` : `Analyse Terminée`}
</span> </span>
</div> </div>
) : ( ) : (
<button <button
onClick={handleAnalyze} onClick={handleAnalyze}
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" className="btn-action btn-action-ai"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
Analyse IA Analyse IA
@@ -212,12 +212,7 @@ export function FloatingSummaryBar() {
alert("Une erreur s'est produite lors de l'export Excel."); alert("Une erreur s'est produite lors de l'export Excel.");
} }
}} }}
className="h-10 px-6 rounded-xl text-sm font-bold transition-all active:scale-95 border hover:brightness-105" className="btn-action btn-action-secondary"
style={{
background: "var(--action-secondary-bg)",
color: "var(--action-secondary-text)",
borderColor: "var(--action-secondary-border)"
}}
> >
Export Excel Export Excel
</button> </button>
@@ -225,10 +220,7 @@ export function FloatingSummaryBar() {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!hasDrafts || isPending} disabled={!hasDrafts || isPending}
className="h-10 px-6 flex items-center justify-center gap-2 rounded-xl text-sm font-black transition-all shadow-lg active:scale-95 text-white hover:brightness-110 disabled:opacity-50" className="btn-action btn-action-primary"
style={{
background: "var(--brand-solid)",
}}
> >
{isPending ? ( {isPending ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
@@ -66,10 +66,10 @@ export function SupplierAiContextModal({ codeFournisseur, nomFournisseur }: Supp
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <button className="btn-action btn-action-secondary">
<Brain className="w-4 h-4" /> <Brain className="w-4 h-4" />
Règles IA Règles IA
</Button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[500px] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl"> <DialogContent className="sm:max-w-[500px] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl">
<DialogHeader> <DialogHeader>
@@ -91,12 +91,12 @@ export function SupplierAiContextModal({ codeFournisseur, nomFournisseur }: Supp
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSaving}> <button className="btn-action btn-action-secondary" onClick={() => setOpen(false)} disabled={isSaving}>
Annuler Annuler
</Button> </button>
<Button onClick={handleSave} disabled={isSaving || isLoading} className="gap-2 bg-emerald-600 hover:bg-emerald-700 text-white"> <button onClick={handleSave} disabled={isSaving || isLoading} className="btn-action btn-action-primary">
{isSaving ? "Enregistrement..." : "Enregistrer les règles"} {isSaving ? "Enregistrement..." : "Enregistrer les règles"}
</Button> </button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>