diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 3c0a18f..ab4a4d0 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -31,10 +31,11 @@ IMPORTANT : La gamme C est RÉSERVÉE aux produits saisonniers et gérée MANUEL ET scoreCritique = true [score brut < 20] → Z DIRECT. Ce produit est marginal et sous-performant. -3. RÈGLE MANAGER : Si le manager a défini une consigne ET que le produit est concerné → Appliquer. +3. RÈGLE MANAGER : Si le manager a défini une consigne ET que le produit est concerné → Appliquer la consigne À LA LETTRE. + Si la règle ordonne explicitement une Gamme B, C ou D, TU DOIS SORTIR "B", "C" ou "D" dans la recommandation JSON. C'est la SEULE exception où tu es autorisé à utiliser B, C ou D. "rule_applies" doit être 'true'. 4. ANALYSE CONTEXTUELLE (si aucune règle ci-dessus ne s'applique) : - Utilise les données de la fiche pour raisonner. Les quadrants sont des INDICES, pas des verdicts. + Utilise les données de la fiche pour raisonner. Les quadrants sont des INDICES, pas des verdicts. Par défaut, génère "A" ou "Z". — Quadrant STAR ⭐ : Fort signal positif → A sauf inactivité ≥ 3 mois. — Quadrant TRAFIC 🚶 : Générateur de flux local. @@ -56,7 +57,7 @@ Ne mets jamais Z un produit si son percentile CA ET son percentile QTÉ sont tou JSON uniquement, sans markdown. { "rule_applies": boolean, - "recommendation": "A" | "Z", + "recommendation": "A" | "B" | "C" | "D" | "Z", "justification": "2 phrases max. Cite poids, percentile, quadrant, score." }`; } @@ -187,15 +188,15 @@ Génère UNIQUEMENT le JSON :`; // Utilitaires de parsing (inchangés) // ----------------------------------------------------------------------- - static extractRecommendation(content: string): "A" | "Z" | null { - const match = content.match(/\b([AZ])\b/i); - if (match) return match[1].toUpperCase() as "A" | "Z"; + static extractRecommendation(content: string): "A" | "B" | "C" | "D" | "Z" | null { + const match = content.match(/\b([ABCDZ])\b/i); + if (match) return match[1].toUpperCase() as "A" | "B" | "C" | "D" | "Z"; return null; } static cleanInsight(content: string): string { let cleaned = content; - cleaned = cleaned.replace(/^\[?[ACZ]\]?\s*[:\s-]+\s*/i, ""); + cleaned = cleaned.replace(/^\[?[ABCDZ]\]?\s*[:\s-]+\s*/i, ""); cleaned = cleaned.replace( /^(justification|explication|pourquoi|justification courte|raison|avis)\s*[:\s-]+\s*/i, "" diff --git a/src/features/ai-copilot/data/open-router-client.ts b/src/features/ai-copilot/data/open-router-client.ts index 37f1cb7..ba8d7f2 100644 --- a/src/features/ai-copilot/data/open-router-client.ts +++ b/src/features/ai-copilot/data/open-router-client.ts @@ -61,7 +61,7 @@ export class OpenRouterClient { const data = await response.json(); const content = data.choices?.[0]?.message?.content ?? ""; - let reco: "A" | "C" | "Z" | null = null; + let reco: "A" | "B" | "C" | "D" | "Z" | null = null; let cleanInsight = "Erreur de génération."; try { @@ -70,16 +70,21 @@ export class OpenRouterClient { const jsonText = jsonMatch ? jsonMatch[0] : content; const parsed = JSON.parse(jsonText); - reco = parsed.recommendation as "A" | "C" | "Z"; + reco = parsed.recommendation as "A" | "B" | "C" | "D" | "Z"; cleanInsight = parsed.justification || ""; - // Garde-fou : C est réservé aux saisonniers (gestion manuelle). - // Si l'IA recommande C malgré l'interdiction dans le prompt → forcer A. - if (reco === "C") reco = "A"; + const ruleApplies = parsed.rule_applies === true; + + // Garde-fou de sécurité : On n'autorise B, C ou D QUE si une règle manager s'applique. + if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) { + reco = "A"; + } // Override recommendation if it doesn't match extracted reco for safety - if (!reco || !["A", "Z"].includes(reco)) { + if (!reco || !["A", "B", "C", "D", "Z"].includes(reco)) { reco = AnalysisEngine.extractRecommendation(cleanInsight) || "A"; + // Ré-appliquer le garde-fou pour la reco extraite du texte. + if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; } } catch (e) { console.error("Failed to parse AI JSON response:", content, e); diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 06ef9af..53b9262 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -62,6 +62,6 @@ export interface ProductAnalysisInput { export interface AnalysisResult { insight: string; codein: string; - recommandation: "A" | "C" | "Z" | null; + recommandation: "A" | "B" | "C" | "D" | "Z" | null; scoring?: any; // On peut typer plus finement si nécessaire } diff --git a/verify-parser.js b/verify-parser.js new file mode 100644 index 0000000..58d88df --- /dev/null +++ b/verify-parser.js @@ -0,0 +1,59 @@ +const fs = require('fs'); + +function extractRecommendation(content) { + const match = content.match(/\b([ABCDZ])\b/i); + if (match) return match[1].toUpperCase(); + return null; +} + +function simulateParse(content) { + let reco = null; + let ruleApplies = false; + + try { + const jsonMatch = content.match(/\{[\s\S]*\}/); + const jsonText = jsonMatch ? jsonMatch[0] : content; + const parsed = JSON.parse(jsonText); + + reco = parsed.recommendation; + ruleApplies = parsed.rule_applies === true; + + if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; + + if (!reco || !["A", "B", "C", "D", "Z"].includes(reco)) { + reco = extractRecommendation(parsed.justification || "") || "A"; + if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; + } + } catch (e) { + reco = extractRecommendation(content) || "A"; + if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; + } + return reco; +} + +const tests = [ + { name: "Valid Rule B", json: '{"rule_applies": true, "recommendation": "B", "justification": "blabla"}', expected: "B" }, + { name: "Valid Rule C", json: '{"rule_applies": true, "recommendation": "C", "justification": "blabla"}', expected: "C" }, + { name: "Valid Rule D", json: '{"rule_applies": true, "recommendation": "D", "justification": "blabla"}', expected: "D" }, + { name: "Invalid Rule B (hallucinated)", json: '{"rule_applies": false, "recommendation": "B", "justification": "blabla"}', expected: "A" }, + { name: "Invalid Rule D (hallucinated)", json: '{"rule_applies": false, "recommendation": "D", "justification": "blabla"}', expected: "A" }, + { name: "Valid Rule A", json: '{"rule_applies": true, "recommendation": "A", "justification": "blabla"}', expected: "A" }, + { name: "Valid Extraction D", json: '{"rule_applies": true, "recommendation": "INVALID", "justification": "Je choisis D."}', expected: "D" }, + { name: "Invalid Extraction D", json: '{"rule_applies": false, "recommendation": "INVALID", "justification": "Je choisis D."}', expected: "A" } +]; + +let allPassed = true; +tests.forEach(t => { + const res = simulateParse(t.json); + const pass = res === t.expected; + console.log(`${pass ? "✅" : "❌"} ${t.name}: expected ${t.expected}, got ${res}`); + if (!pass) allPassed = false; +}); + +if (allPassed) { + console.log("All parsing tests passed."); + process.exit(0); +} else { + console.log("Some tests failed."); + process.exit(1); +} diff --git a/verify-parser.ts b/verify-parser.ts new file mode 100644 index 0000000..f6e7299 --- /dev/null +++ b/verify-parser.ts @@ -0,0 +1,55 @@ + +import { AnalysisEngine } from "./src/features/ai-copilot/business/analysis-engine"; + +console.log("--- Testing AI Parser Logic for B/C ---"); + +function simulateParse(content: string, simulateRuleResponse = false) { + let reco: "A" | "B" | "C" | "Z" | null = null; + let ruleApplies = simulateRuleResponse; + + try { + const jsonMatch = content.match(/\{[\s\S]*\}/); + const jsonText = jsonMatch ? jsonMatch[0] : content; + const parsed = JSON.parse(jsonText); + + reco = parsed.recommendation as "A" | "B" | "C" | "Z"; + ruleApplies = parsed.rule_applies === true; + + if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A"; + + if (!reco || !["A", "B", "C", "Z"].includes(reco)) { + reco = AnalysisEngine.extractRecommendation(parsed.justification || "") || "A"; + if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A"; + } + } catch (e) { + reco = AnalysisEngine.extractRecommendation(content); + if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A"; + } + return reco; +} + +const tests = [ + { name: "Valid Rule B", json: '{"rule_applies": true, "recommendation": "B", "justification": "blabla"}', expected: "B" }, + { name: "Valid Rule C", json: '{"rule_applies": true, "recommendation": "C", "justification": "blabla"}', expected: "C" }, + { name: "Invalid Rule B (hallucinated)", json: '{"rule_applies": false, "recommendation": "B", "justification": "blabla"}', expected: "A" }, + { name: "Invalid Rule C (hallucinated)", json: '{"rule_applies": false, "recommendation": "C", "justification": "blabla"}', expected: "A" }, + { name: "Valid Rule A", json: '{"rule_applies": true, "recommendation": "A", "justification": "blabla"}', expected: "A" }, + { name: "Valid Extraction B", json: '{"rule_applies": true, "recommendation": "INVALID", "justification": "Je choisis B."}', expected: "B" }, + { name: "Invalid Extraction B", json: '{"rule_applies": false, "recommendation": "INVALID", "justification": "Je choisis B."}', expected: "A" } +]; + +let allPassed = true; +tests.forEach(t => { + const res = simulateParse(t.json); + const pass = res === t.expected; + console.log(`${pass ? "✅" : "❌"} ${t.name}: expected ${t.expected}, got ${res}`); + if (!pass) allPassed = false; +}); + +if (allPassed) { + console.log("All parsing tests passed."); + process.exit(0); +} else { + console.log("Some tests failed."); + process.exit(1); +}