feat: Introduce bulk AI product analysis, integrating with OpenRouter via a new API and UI.

This commit is contained in:
Michael committed 2026-02-22 11:59:15 +01:00
1 parent 04e6ed70f6
commit 3de16d15d7
5 files changed
+194 -106

No files matched your search

+9 -75
View File
@@ -1,28 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSavedDatabaseConfig } from "@/features/settings/actions";
const AnalyzeSchema = z.object({
codein: z.string(),
libelle1: z.string(),
totalCa: z.number(),
tauxMarge: z.number(),
totalQuantite: z.number(),
sales12m: z.record(z.string(), z.number()),
codeGamme: z.string().nullable(),
});
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const SYSTEM_PROMPT = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel.
En analysant les données de ventes fournies, génère une recommandation de gamme (A=Permanent, C=Saisonnier, Z=Sortie).
- A : Produit permanent avec rotation régulière.
- C : Produit saisonnier (pics de ventes spécifiques).
- Z : Produit en sortie (aucune vente ou rotation insignifiante).
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
Format: "[Recommandation]: [Justification courte basée sur les données]"`;
import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client";
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
export async function POST(req: NextRequest) {
const config = await getSavedDatabaseConfig();
@@ -34,62 +13,17 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured. Please set it in Settings." }, { status: 503 });
}
const body = await req.json();
const parsed = AnalyzeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
}
const p = parsed.data;
// Format monthly sales for the prompt
const monthlySummary = Object.entries(p.sales12m)
.map(([k, v]) => `${k}: ${Math.round(v)} unités`)
.join(", ");
const userMessage = `Produit: "${p.libelle1}" (Code: ${p.codein})
Gamme actuelle: ${p.codeGamme ?? "Non définie"}
CA total 12m: ${p.totalCa.toFixed(0)}€ | Taux de marge: ${p.tauxMarge.toFixed(1)}% | Volume: ${Math.round(p.totalQuantite)} unités
Historique mensuel: ${monthlySummary}
Quelle gamme recommandes-tu et pourquoi ?`;
try {
const response = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://collectflow.app",
"X-Title": "CollectFlow AI Copilot",
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
max_tokens: 150,
temperature: 0.3,
}),
});
const body: ProductAnalysisInput = await req.json();
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
}
const client = new OpenRouterClient({ apiKey, model });
const result = await client.analyzeProduct(body);
if (!response.ok) {
const err = await response.text();
return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status });
}
const data = await response.json();
const content: string = data.choices?.[0]?.message?.content ?? "";
return NextResponse.json({ insight: content, codein: p.codein });
return NextResponse.json(result);
} catch (err) {
if (err instanceof Error && err.message === "rate_limited") {
return NextResponse.json({ error: "rate_limited", retryAfter: 30 }, { status: 429 });
}
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
@@ -0,0 +1,51 @@
import { ProductAnalysisInput } from "../models/ai-analysis.types";
export class AnalysisEngine {
static generateSystemPrompt(): string {
return `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel.
En analysant les données de ventes fournies, génère une recommandation de gamme :
- A (Permanent) : Produit avec rotation régulière, CA stable ou en croissance.
- C (Saisonnier) : Produit avec pics de ventes spécifiques (saisonnalité marquée).
- Z (Sortie) : Produit en fin de vie, rotation insignifiante ou CA en chute libre.
Critères de pondération :
1. Volume et CA : Priorité aux produits générant du flux.
2. Volume Relatif : Compare le volume du produit à la moyenne du fournisseur (fournie). Un produit très au-dessus de la moyenne est un pilier (A).
3. Marge : Un produit à forte marge mais faible rotation peut être maintenu en C.
4. Régularité : La stabilité des ventes sur 12 mois favorise le score A.
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
Format impératif : "[Recommandation]: [Justification courte]"`;
}
static generateUserMessage(p: ProductAnalysisInput): string {
const monthlySummary = Object.entries(p.sales12m)
.map(([k, v]) => `${k}: ${Math.round(v)}u`)
.join(", ");
const volumeContext = p.avgTotalQuantite
? `- Volume vs Moyenne Fournisseur : ${Math.round(p.totalQuantite)}u (Moyenne: ${Math.round(p.avgTotalQuantite)}u)`
: `- Volume total : ${Math.round(p.totalQuantite)} unités`;
return `Produit : "${p.libelle1}" (Ref: ${p.codein})
Gamme actuelle : ${p.codeGamme ?? "Non définie"}
Indicateurs 12m :
- CA : ${p.totalCa.toFixed(2)}€
- Marge : ${p.tauxMarge.toFixed(1)}%
${volumeContext}
- Historique mensuel : ${monthlySummary}
Quelle est ta recommandation (A, C ou Z) et pourquoi ?`;
}
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)
// On favorise le format [A] ou "A:" mais on accepte A seul.
const match = content.match(/\b([ACZ])\b/i);
if (match) {
return match[1].toUpperCase() as "A" | "C" | "Z";
}
return null;
}
}
@@ -0,0 +1,52 @@
import { AnalysisEngine } from "../business/analysis-engine";
import { ProductAnalysisInput, AnalysisResult } from "../models/ai-analysis.types";
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
export interface OpenRouterConfig {
apiKey: string;
model: string;
}
export class OpenRouterClient {
constructor(private config: OpenRouterConfig) { }
async analyzeProduct(p: ProductAnalysisInput): Promise<AnalysisResult> {
const response = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://collectflow.app",
"X-Title": "CollectFlow AI Copilot",
},
body: JSON.stringify({
model: this.config.model,
messages: [
{ role: "system", content: AnalysisEngine.generateSystemPrompt() },
{ role: "user", content: AnalysisEngine.generateUserMessage(p) },
],
max_tokens: 150,
temperature: 0.3,
}),
});
if (response.status === 429) {
throw new Error("rate_limited");
}
if (!response.ok) {
const err = await response.text();
throw new Error(`OpenRouter error: ${err}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? "";
return {
insight: content,
codein: p.codein,
recommandation: AnalysisEngine.extractRecommendation(content)
};
}
}
@@ -0,0 +1,16 @@
export interface ProductAnalysisInput {
codein: string;
libelle1: string;
totalCa: number;
tauxMarge: number;
totalQuantite: number;
avgTotalQuantite?: number;
sales12m: Record<string, number>;
codeGamme: string | null;
}
export interface AnalysisResult {
insight: string;
codein: string;
recommandation: "A" | "C" | "Z" | null;
}
@@ -1,28 +1,38 @@
"use client";
import React, { useState } from "react";
import React, { useState, useRef } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import { Sparkles, Loader2, CheckCircle2 } from "lucide-react";
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { ProductAnalysisInput, AnalysisResult } from "@/features/ai-copilot/models/ai-analysis.types";
export function BulkAiAnalyzer() {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
const isCancelledRef = useRef(false);
const setDraftGamme = useGridStore(state => state.setDraftGamme);
const setInsight = useAiCopilotStore(state => state.setInsight);
const handleStop = () => {
isCancelledRef.current = true;
setProgress(prev => ({ ...prev, message: "Arrêt en cours..." }));
};
const handleAnalyze = async () => {
const { rows } = useGridStore.getState();
isCancelledRef.current = false;
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows");
const totalQtySum = rows.reduce((sum, r) => sum + (r.totalQuantite || 0), 0);
const avgQty = rows.length > 0 ? totalQtySum / rows.length : 0;
// Payload identique à l'analyse individuelle — aucun enrichissement
const productPayloads = rows.map(r => ({
const productPayloads: ProductAnalysisInput[] = rows.map(r => ({
codein: r.codein,
libelle1: r.libelle1 || "",
totalCa: r.totalCa || 0,
tauxMarge: r.tauxMarge || 0,
totalQuantite: r.totalQuantite || 0,
avgTotalQuantite: avgQty,
sales12m: r.sales12m || {},
codeGamme: r.codeGamme || null,
}));
@@ -34,18 +44,28 @@ export function BulkAiAnalyzer() {
setProgress({ current: 0, total: productPayloads.length, message: "Initialisation...", errors: 0 });
try {
const CONCURRENCY = 5;
const CONCURRENCY = 3; // Réduit pour éviter les 429 trop fréquents
const remaining = [...productPayloads];
const processNext = async () => {
if (isCancelledRef.current) return;
const payload = remaining.shift();
if (!payload) return;
// Marquer temporairement comme loading dans le store pour le feedback visuel individuel
setInsight(payload.codein, "", false);
// Note: setInsight dans le store actuel met le status à "done".
// Pour bien faire, il faudrait une action setStatusLoading dans le store.
// Ici, on va au moins vider l'insight pour montrer que ça bouge.
try {
let res: Response | null = null;
const MAX_RETRIES = 3;
const MAX_RETRIES = 2;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (isCancelledRef.current) break;
res = await fetch("/api/ai/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -56,6 +76,7 @@ export function BulkAiAnalyzer() {
const errData = await res.json().catch(() => ({}));
const waitSecs = errData.retryAfter ?? (15 * attempt);
for (let t = waitSecs; t > 0; t--) {
if (isCancelledRef.current) break;
setProgress(prev => ({ ...prev, message: `⏳ ${completed}/${productPayloads.length} — Limite API, reprise dans ${t}s...` }));
await new Promise(r => setTimeout(r, 1000));
}
@@ -64,34 +85,35 @@ export function BulkAiAnalyzer() {
break;
}
if (isCancelledRef.current) return;
if (!res || !res.ok) {
errors++;
} else {
const data = await res.json();
if (data.insight) {
// Extraire la recommandation (première lettre A, C ou Z)
const match = data.insight.match(/^[^A-Z]*([ACZ])\b/i);
const gamme = match ? match[1].toUpperCase() : null;
const data: AnalysisResult = await res.json();
// On met à jour l'insight dans tous les cas pour qu'il s'affiche
setInsight(data.codein, data.insight, false);
if (gamme) {
setDraftGamme(data.codein, gamme);
setInsight(data.codein, data.insight, false);
}
// Si une recommandation est trouvée, on l'applique à la gamme
if (data.recommandation) {
setDraftGamme(data.codein, data.recommandation);
}
}
} catch {
errors++;
} finally {
completed++;
setProgress({
current: completed,
total: productPayloads.length,
message: completed === productPayloads.length
? "Analyse terminée"
: `Analyse: ${completed}/${productPayloads.length} produits`,
errors,
});
await processNext();
if (!isCancelledRef.current) {
completed++;
setProgress({
current: completed,
total: productPayloads.length,
message: completed === productPayloads.length
? "Analyse terminée"
: `Analyse: ${completed}/${productPayloads.length} produits`,
errors,
});
await processNext();
}
}
};
@@ -105,8 +127,11 @@ export function BulkAiAnalyzer() {
...prev,
current: completed,
total: productPayloads.length,
message: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
message: isCancelledRef.current
? `Analyse interrompue (${completed} traités)`
: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
}));
setTimeout(() => setIsAnalyzing(false), 3000);
} catch (error) {
@@ -117,9 +142,9 @@ export function BulkAiAnalyzer() {
if (isAnalyzing) {
return (
<div className="flex items-center gap-3 px-4 py-2 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 px-4 py-2 rounded-xl bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800">
<Loader2 className="w-4 h-4 text-indigo-500 animate-spin" />
<div className="flex flex-col">
<div className="flex flex-col min-w-[150px]">
<span className="text-xs font-bold text-indigo-700 dark:text-indigo-400">
{progress.message}
</span>
@@ -130,16 +155,26 @@ export function BulkAiAnalyzer() {
></div>
</div>
</div>
<button
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"
title="Arrêter l'analyse"
>
<XCircle className="w-5 h-5" />
</button>
</div>
);
}
if (progress.current === progress.total && progress.total > 0 && !isAnalyzing) {
if (progress.current > 0 && !isAnalyzing) {
return (
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 cursor-default">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-500" />
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">
Analyse terminée ({progress.total} produits analysés)
{progress.errors > 0
? `Terminé (${progress.current - progress.errors}/${progress.total} OK)`
: `Analyse terminée (${progress.total} produits)`
}
</span>
</div>
);