mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement AI Copilot for product analysis, featuring dedicated API, business logic, UI components, and comprehensive documentation.
This commit is contained in:
1 parent
7a6b515db7
commit
1c03403e9a
8 files changed
+229
-11
No files matched your search
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user