feat: implement AI Copilot for product analysis, featuring dedicated API, business logic, UI components, and comprehensive documentation.

This commit is contained in:
Michael committed 2026-02-25 10:58:12 +01:00
1 parent 7a6b515db7
commit 1c03403e9a
8 files changed
+229 -11

No files matched your search

+15 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Bot, TrendingUp, Calendar, Zap, AlertTriangle, ShieldCheck, Scale, Info } from "lucide-react";
import { Bot, TrendingUp, Calendar, Zap, AlertTriangle, ShieldCheck, Scale, Info, Brain } from "lucide-react";
import { cn } from "@/lib/utils";
export default function AiAnalysisDocPage() {
@@ -83,6 +83,20 @@ export default function AiAnalysisDocPage() {
À l'inverse, un produit à faible volume peut être un <strong>Contributeur de Marge</strong> précieux s'il possède un Prix Moyen de Vente (PMV) élevé.
</p>
</DocCard>
{/* 5. Règles IA Fournisseur */}
<DocCard
icon={Brain}
title="Règles Métier Fournisseur"
description="Priorité absolue aux directives personnalisées."
accent="indigo"
>
<p className="text-[13px] leading-relaxed">
Vous pouvez définir des <strong>Règles IA</strong> spécifiques pour chaque fournisseur (via le bouton avec le cerveau 🧠).
Ces directives sont mémorisées d'une session à l'autre et l'IA a pour consigne stricte de les <strong>respecter en priorité absolue</strong>
sur les calculs algorithmiques standards.
</p>
</DocCard>
</div>
{/* Footer / Tip */}
+18
View File
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { getSavedDatabaseConfig } from "@/features/settings/actions";
import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client";
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
import { db } from "@/db";
import { aiSupplierContext } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function POST(req: NextRequest) {
const config = await getSavedDatabaseConfig();
@@ -16,6 +19,21 @@ export async function POST(req: NextRequest) {
try {
const body: ProductAnalysisInput = await req.json();
// Inject supplier context if available
if (body.codeFournisseur) {
try {
const contextRecord = await db.select()
.from(aiSupplierContext)
.where(eq(aiSupplierContext.codeFournisseur, body.codeFournisseur))
.limit(1);
if (contextRecord.length > 0 && contextRecord[0].context) {
body.supplierContext = contextRecord[0].context;
}
} catch (err) {
console.error("[AI] Failed to fetch supplier context:", err);
}
}
const client = new OpenRouterClient({ apiKey, model });
const result = await client.analyzeProduct(body);
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { db } from "@/db";
import { aiSupplierContext } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const codeFournisseur = searchParams.get("fournisseur");
if (!codeFournisseur) {
return NextResponse.json({ error: "Fournisseur manquant" }, { status: 400 });
}
try {
const result = await db.select()
.from(aiSupplierContext)
.where(eq(aiSupplierContext.codeFournisseur, codeFournisseur))
.limit(1);
return NextResponse.json({ context: result[0]?.context || "" });
} catch (error) {
console.error("Error fetching AI context:", error);
// Fallback en cas d'erreur de BDD ou si la table n'est pas encore créée
return NextResponse.json({ context: "" });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { codeFournisseur, context } = body;
if (!codeFournisseur) {
return NextResponse.json({ error: "Fournisseur manquant" }, { status: 400 });
}
await db.insert(aiSupplierContext)
.values({
codeFournisseur,
context: context || "",
updatedAt: new Date()
})
.onConflictDoUpdate({
target: aiSupplierContext.codeFournisseur,
set: {
context: context || "",
updatedAt: new Date()
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error saving AI context:", error);
return NextResponse.json({ error: "Erreur lors de la sauvegarde" }, { status: 500 });
}
}
+10
View File
@@ -76,3 +76,13 @@ export const sessionSnapshots = pgTable("session_snapshots", {
type: varchar("type", { length: 20 }).default("snapshot"),
createdAt: timestamp("created_at").defaultNow(),
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
/** The business rules text provided by the user */
context: text("context").notNull(),
/** When it was last updated */
updatedAt: timestamp("updated_at").defaultNow(),
});
@@ -38,9 +38,15 @@ CONTRIBUTION(%) :
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 ces règles absolues pour ce fournisseur (TU DOIS LES RESPECTER EN PRIORITÉ) :
"${p.supplierContext}"
` : "";
return `PRODUIT: ${p.libelle1} (${p.codein})
PERFORMANCE: Score Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€
${weights}${scoringInfo}
${weights}${scoringInfo}${contextRules}
Verdict algorithmique: ${p.scoring?.decision || "Non calculé"}
Justifie ce verdict auprès de l'utilisateur.`;
}
@@ -31,16 +31,18 @@ export interface ProductAnalysisInput {
codeFournisseur?: string;
totalMagasins?: number;
isLastProductOfSupplier?: boolean;
/** Résultats du ScoringEngine (optionnel pour l'IA) */
/** Scoring metadata */
scoring?: {
compositeScore: number;
decision: string;
decision: "A" | "Z";
labelProfil: string;
isTop30Supplier: boolean;
isRecent: boolean;
isLastProduct: boolean;
threshold: number;
};
/** Optional context rules for the supplier */
supplierContext?: string;
}
export interface AnalysisResult {
@@ -7,8 +7,13 @@ import { ScoringEngine } from "@/features/ai-copilot/business/scoring-engine";
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { ProductRow, GammeCode } from "@/types/grid";
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
import { SupplierAiContextModal } from "./supplier-ai-context-modal";
export function BulkAiAnalyzer() {
const rows = useGridStore((s) => s.rows);
const supplierCode = rows.length > 0 ? rows[0].codeFournisseur : null;
const supplierName = rows.length > 0 ? rows[0].nomFournisseur : null;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
const isCancelledRef = useRef(false);
@@ -243,12 +248,15 @@ export function BulkAiAnalyzer() {
}
return (
<button
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"
>
<Sparkles className="w-4 h-4" />
Analyse IA
</button>
<div className="flex items-center gap-3">
<SupplierAiContextModal codeFournisseur={supplierCode} nomFournisseur={supplierName} />
<button
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"
>
<Sparkles className="w-4 h-4" />
Analyse IA
</button>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Brain } from "lucide-react";
interface SupplierAiContextModalProps {
codeFournisseur: string | null;
nomFournisseur: string | null;
}
export function SupplierAiContextModal({ codeFournisseur, nomFournisseur }: SupplierAiContextModalProps) {
const [open, setOpen] = useState(false);
const [context, setContext] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (open && codeFournisseur) {
fetchContext();
}
}, [open, codeFournisseur]);
const fetchContext = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/ai/context?fournisseur=${codeFournisseur}`);
if (res.ok) {
const data = await res.json();
setContext(data.context || "");
}
} catch (error) {
console.error("Failed to load AI context", error);
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!codeFournisseur) return;
setIsSaving(true);
try {
const res = await fetch("/api/ai/context", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeFournisseur, context }),
});
if (res.ok) {
setOpen(false);
} else {
console.error("Failed to save AI context");
}
} catch (error) {
console.error("Failed to save AI context", error);
} finally {
setIsSaving(false);
}
};
if (!codeFournisseur) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Brain className="w-4 h-4" />
Règles IA
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Contexte IA - {nomFournisseur || codeFournisseur}</DialogTitle>
<DialogDescription>
Définissez ici les règles métier absolues pour ce fournisseur.
Mary (l'IA) respectera ces consignes en priorité lors de ses recommandations (ex: "Les calendriers vont en gamme C").
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
placeholder="Ex: Les produits dont le nom contient 'Agenda' doivent toujours être classés en [A]..."
value={context}
onChange={(e) => setContext(e.target.value)}
disabled={isLoading}
className="min-h-[150px]"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSaving}>
Annuler
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading} className="gap-2 bg-gradient-to-r from-violet-600 to-indigo-600 text-white hover:from-violet-700 hover:to-indigo-700">
{isSaving ? "Enregistrement..." : "Enregistrer les règles"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}