feat: implement AI Copilot for product analysis and recommendation, including a new analysis engine, OpenRouter client, and parser verification.

This commit is contained in:
Michael committed 2026-03-05 11:43:58 +01:00
1 parent a008b40a59
commit 2edf6202ab
5 files changed
+134 -14

No files matched your search

@@ -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,
""
@@ -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);
@@ -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
}
+59
View File
@@ -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);
}
+55
View File
@@ -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);
}