mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 14:38:10 +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";
|
"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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function AiAnalysisDocPage() {
|
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é.
|
À 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>
|
</p>
|
||||||
</DocCard>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer / Tip */}
|
{/* Footer / Tip */}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getSavedDatabaseConfig } from "@/features/settings/actions";
|
import { getSavedDatabaseConfig } from "@/features/settings/actions";
|
||||||
import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client";
|
import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client";
|
||||||
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
const config = await getSavedDatabaseConfig();
|
const config = await getSavedDatabaseConfig();
|
||||||
@@ -16,6 +19,21 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body: ProductAnalysisInput = await req.json();
|
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 client = new OpenRouterClient({ apiKey, model });
|
||||||
const result = await client.analyzeProduct(body);
|
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"),
|
type: varchar("type", { length: 20 }).default("snapshot"),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
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"}
|
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})
|
return `PRODUIT: ${p.libelle1} (${p.codein})
|
||||||
PERFORMANCE: Score Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€
|
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é"}
|
Verdict algorithmique: ${p.scoring?.decision || "Non calculé"}
|
||||||
Justifie ce verdict auprès de l'utilisateur.`;
|
Justifie ce verdict auprès de l'utilisateur.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,16 +31,18 @@ export interface ProductAnalysisInput {
|
|||||||
codeFournisseur?: string;
|
codeFournisseur?: string;
|
||||||
totalMagasins?: number;
|
totalMagasins?: number;
|
||||||
isLastProductOfSupplier?: boolean;
|
isLastProductOfSupplier?: boolean;
|
||||||
/** Résultats du ScoringEngine (optionnel pour l'IA) */
|
/** Scoring metadata */
|
||||||
scoring?: {
|
scoring?: {
|
||||||
compositeScore: number;
|
compositeScore: number;
|
||||||
decision: string;
|
decision: "A" | "Z";
|
||||||
labelProfil: string;
|
labelProfil: string;
|
||||||
isTop30Supplier: boolean;
|
isTop30Supplier: boolean;
|
||||||
isRecent: boolean;
|
isRecent: boolean;
|
||||||
isLastProduct: boolean;
|
isLastProduct: boolean;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
};
|
};
|
||||||
|
/** Optional context rules for the supplier */
|
||||||
|
supplierContext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalysisResult {
|
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 { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||||
import { ProductRow, GammeCode } from "@/types/grid";
|
import { ProductRow, GammeCode } from "@/types/grid";
|
||||||
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
|
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
|
||||||
|
import { SupplierAiContextModal } from "./supplier-ai-context-modal";
|
||||||
|
|
||||||
export function BulkAiAnalyzer() {
|
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 [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
|
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
|
||||||
const isCancelledRef = useRef(false);
|
const isCancelledRef = useRef(false);
|
||||||
@@ -243,12 +248,15 @@ export function BulkAiAnalyzer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={handleAnalyze}
|
<SupplierAiContextModal codeFournisseur={supplierCode} nomFournisseur={supplierName} />
|
||||||
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"
|
<button
|
||||||
>
|
onClick={handleAnalyze}
|
||||||
<Sparkles className="w-4 h-4" />
|
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"
|
||||||
Analyse IA
|
>
|
||||||
</button>
|
<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