diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 3e83427..1a481f4 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -4,28 +4,21 @@ export class AnalysisEngine { static generateSystemPrompt(): string { return `Tu es Mary, experte Senior en Stratégie Retail algorithmique. Ta mission est d'expliquer le verdict pour un produit. ---- RÈGLE D'OR ABSOLUE --- -Si des "RÈGLES MÉTIER SPÉCIFIQUES" sont fournies, tu dois d'abord vérifier si le produit évalué correspond EXACTEMENT au critère de la règle. -- Exemple: Si la règle dit "Les agendas en A", et que le produit est un "Calendrier", la règle NE S'APPLIQUE PAS. L'algorithme s'applique normalement. -- Si la règle S'APPLIQUE BIEN au produit (ex: le produit est bien un "Agenda"), ALORS LA RÈGLE ÉCRASE TOTALEMENT L'ALGORITHME. Donne la lettre imposée et justifie uniquement en citant la règle. +--- RÈGLES DE DÉCISION --- +1. RÈGLE MÉTIER SPÉCIFIQUE (Priorité Absolue) : Si le manager a défini une règle et que LE PRODUIT ACTUEL correspond exactement à la condition de cette règle, le score et l'algorithme sont annulés. Tu dois suivre la recommandation de la règle et justifier par "Selon vos consignes...". +2. ALGORITHME (Par défaut) : Si aucune règle métier n'est fournie OU si le produit actuel n'est pas ciblé par la règle, base-toi sur le PMV, la Marge, le CA et les Quantités. Le score (0 à 100) est une mesure relative (100 = Excellence, <30 = Faible). ---- INTERPRÉTATION ALGORITHMIQUE (Si aucune règle ne s'applique) --- -Le score (0 à 100) est une mesure de performance RELATIVE : -- 100/100 : Excellence. -- > 70/100 : Très bonne performance. -- 30-70/100 : Performance moyenne. -- < 30/100 : Faible. +--- Interdictions --- +Ne mentionne jamais de mois, de tendances ou de prédictions. Sois très concise (2 phrases max). ---- TON RÔLE --- -1. JUSTIFIER : Explique la gamme choisie. Si c'est basé sur une Règle Métier, dis "Selon vos règles: [règle]". Sinon, base-toi sur le PMV, la Marge, le CA et les Quantités. -2. NE JAMAIS mentionner de mois ou prédictions. -3. CONCISE : 2 phrases maximum. - ---- FORMAT ATTENDU (La lettre doit être isolée au début) --- -"[Recommandation_Finale] : [Explication]" -Exemple 1 (Règle Métier) : "C : Selon vos consignes, les calendriers vont en C, indépendamment des ventes." -Exemple 2 (Algo) : "A : Score de 85/100 justifié par une très forte marge (45%) et une excellente contribution." -Exemple 3 (Algo Z) : "Z : Rétrogradé malgré de bons volumes car le score global (15/100) est critique."`; +--- FORMAT DE RÉPONSE OBLIGATOIRE --- +Tu dois UNIQUEMENT répondre avec un objet JSON valide, sans markdown, sans \`\`\`json. +Structure attendue : +{ + "rule_applies": boolean, // true si le produit est ciblé par la règle métier fournie, sinon false + "recommendation": "A" | "C" | "Z", // La lettre finale retenue + "justification": "Texte court expliquant le choix." +}`; } static generateUserMessage(p: ProductAnalysisInput): string { @@ -47,19 +40,19 @@ Exemple 3 (Algo Z) : "Z : Rétrogradé malgré de bons volumes car le score glob const contextRules = p.supplierContext ? ` --- RÈGLES MÉTIER SPÉCIFIQUES --- -Le manager a défini cette règle pour ce fournisseur : +Le manager a défini cette consigne pour ce fournisseur : "${p.supplierContext}" -ATTENTION: Tu dois d'abord juger si LE PRODUIT ACTUEL ("${p.libelle1}") est ciblé par la condition de cette règle. -- S'il est ciblé : Applique la lettre exigée par la règle et ignore l'algo. -- S'il n'est PAS ciblé : Ignore complètement cette règle et base-toi uniquement sur le "Verdict purement algorithmique". +Attention : Évalue d'abord si le produit ("${p.libelle1}") est concerné par cette consigne. Si oui, \`rule_applies\` doit être \`true\`. Sinon, \`false\`. ` : ""; - return `PRODUIT: ${p.libelle1} (${p.codein}) -PERFORMANCE: Score Global ${p.score.toFixed(1)}/100 -KPIs: CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo} -Verdict purement algorithmique: ${p.scoring?.decision || "Non calculé"}${contextRules} -Ta tâche : Donne la recommandation finale (A, C ou Z) et justifie-la factuellement.`; + return `PRODUIT : ${p.libelle1} (${p.codein}) +Famille / Rayon : ${p.libelleNiveau2} +Score Algorithmique : ${p.score.toFixed(1)}/100 +KPIs : CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo} +Verdict purement algorithmique : ${p.scoring?.decision || "Non calculé"}${contextRules} + +Génère UNIQUEMENT le JSON :`; } static extractRecommendation(content: string): "A" | "C" | "Z" | null { diff --git a/src/features/ai-copilot/data/open-router-client.ts b/src/features/ai-copilot/data/open-router-client.ts index 90337a5..c1919ae 100644 --- a/src/features/ai-copilot/data/open-router-client.ts +++ b/src/features/ai-copilot/data/open-router-client.ts @@ -26,8 +26,9 @@ export class OpenRouterClient { { role: "system", content: AnalysisEngine.generateSystemPrompt() }, { role: "user", content: AnalysisEngine.generateUserMessage(p) }, ], - max_tokens: 150, - temperature: 0.3, + response_format: { type: "json_object" }, + max_tokens: 200, + temperature: 0.1, }), }); @@ -42,8 +43,29 @@ export class OpenRouterClient { const data = await response.json(); const content = data.choices?.[0]?.message?.content ?? ""; - const reco = AnalysisEngine.extractRecommendation(content); - const cleanInsight = AnalysisEngine.cleanInsight(content); + + let reco: "A" | "C" | "Z" | null = null; + let cleanInsight = "Erreur de génération."; + + try { + // Some models might wrap JSON in markdown blocks despite instructions + const jsonMatch = content.match(/\{[\s\S]*\}/); + const jsonText = jsonMatch ? jsonMatch[0] : content; + const parsed = JSON.parse(jsonText); + + reco = parsed.recommendation as "A" | "C" | "Z"; + cleanInsight = parsed.justification || ""; + + // Override recommendation if it doesn't match extracted reco for safety + if (!reco || !["A", "C", "Z"].includes(reco)) { + reco = AnalysisEngine.extractRecommendation(cleanInsight) || "A"; + } + } catch (e) { + console.error("Failed to parse AI JSON response:", content, e); + // Fallback to text parsing + reco = AnalysisEngine.extractRecommendation(content); + cleanInsight = AnalysisEngine.cleanInsight(content); + } return { insight: reco ? `[${reco}] ${cleanInsight}` : cleanInsight,