From 3de16d15d7a464cdb1fe9a8a42cc44ebc4a52bfa Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 11:59:15 +0100 Subject: [PATCH] feat: Introduce bulk AI product analysis, integrating with OpenRouter via a new API and UI. --- src/app/api/ai/analyze/route.ts | 84 ++-------------- .../ai-copilot/business/analysis-engine.ts | 51 ++++++++++ .../ai-copilot/data/open-router-client.ts | 52 ++++++++++ .../ai-copilot/models/ai-analysis.types.ts | 16 +++ .../grid/components/bulk-ai-analyzer.tsx | 97 +++++++++++++------ 5 files changed, 194 insertions(+), 106 deletions(-) create mode 100644 src/features/ai-copilot/business/analysis-engine.ts create mode 100644 src/features/ai-copilot/data/open-router-client.ts create mode 100644 src/features/ai-copilot/models/ai-analysis.types.ts diff --git a/src/app/api/ai/analyze/route.ts b/src/app/api/ai/analyze/route.ts index 397fbde..c97ad9d 100644 --- a/src/app/api/ai/analyze/route.ts +++ b/src/app/api/ai/analyze/route.ts @@ -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 }); } diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts new file mode 100644 index 0000000..e812570 --- /dev/null +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -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; + } +} diff --git a/src/features/ai-copilot/data/open-router-client.ts b/src/features/ai-copilot/data/open-router-client.ts new file mode 100644 index 0000000..2ba89d8 --- /dev/null +++ b/src/features/ai-copilot/data/open-router-client.ts @@ -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 { + 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) + }; + } +} diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts new file mode 100644 index 0000000..3060e45 --- /dev/null +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -0,0 +1,16 @@ +export interface ProductAnalysisInput { + codein: string; + libelle1: string; + totalCa: number; + tauxMarge: number; + totalQuantite: number; + avgTotalQuantite?: number; + sales12m: Record; + codeGamme: string | null; +} + +export interface AnalysisResult { + insight: string; + codein: string; + recommandation: "A" | "C" | "Z" | null; +} diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 7c83893..c3fc49a 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -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 ( -
+
-
+
{progress.message} @@ -130,16 +155,26 @@ export function BulkAiAnalyzer() { >
+
); } - if (progress.current === progress.total && progress.total > 0 && !isAnalyzing) { + if (progress.current > 0 && !isAnalyzing) { return (
- 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)` + }
);