mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 05:28:11 +02:00
fix(ai): force json-structured reasoning to prevent business rule bleed
This commit is contained in:
1 parent
315a223021
commit
c3f511b612
2 files changed
+48
-33
No files matched your search
@@ -4,28 +4,21 @@ export class AnalysisEngine {
|
|||||||
static generateSystemPrompt(): string {
|
static generateSystemPrompt(): string {
|
||||||
return `Tu es Mary, experte Senior en Stratégie Retail algorithmique. Ta mission est d'expliquer le verdict pour un produit.
|
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 ---
|
--- RÈGLES DE DÉCISION ---
|
||||||
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.
|
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...".
|
||||||
- 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.
|
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).
|
||||||
- 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.
|
|
||||||
|
|
||||||
--- INTERPRÉTATION ALGORITHMIQUE (Si aucune règle ne s'applique) ---
|
--- Interdictions ---
|
||||||
Le score (0 à 100) est une mesure de performance RELATIVE :
|
Ne mentionne jamais de mois, de tendances ou de prédictions. Sois très concise (2 phrases max).
|
||||||
- 100/100 : Excellence.
|
|
||||||
- > 70/100 : Très bonne performance.
|
|
||||||
- 30-70/100 : Performance moyenne.
|
|
||||||
- < 30/100 : Faible.
|
|
||||||
|
|
||||||
--- TON RÔLE ---
|
--- FORMAT DE RÉPONSE OBLIGATOIRE ---
|
||||||
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.
|
Tu dois UNIQUEMENT répondre avec un objet JSON valide, sans markdown, sans \`\`\`json.
|
||||||
2. NE JAMAIS mentionner de mois ou prédictions.
|
Structure attendue :
|
||||||
3. CONCISE : 2 phrases maximum.
|
{
|
||||||
|
"rule_applies": boolean, // true si le produit est ciblé par la règle métier fournie, sinon false
|
||||||
--- FORMAT ATTENDU (La lettre doit être isolée au début) ---
|
"recommendation": "A" | "C" | "Z", // La lettre finale retenue
|
||||||
"[Recommandation_Finale] : [Explication]"
|
"justification": "Texte court expliquant le choix."
|
||||||
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."`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static generateUserMessage(p: ProductAnalysisInput): string {
|
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 ? `
|
const contextRules = p.supplierContext ? `
|
||||||
--- RÈGLES MÉTIER SPÉCIFIQUES ---
|
--- 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}"
|
"${p.supplierContext}"
|
||||||
|
|
||||||
ATTENTION: Tu dois d'abord juger si LE PRODUIT ACTUEL ("${p.libelle1}") est ciblé par la condition de cette règle.
|
Attention : Évalue d'abord si le produit ("${p.libelle1}") est concerné par cette consigne. Si oui, \`rule_applies\` doit être \`true\`. Sinon, \`false\`.
|
||||||
- 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".
|
|
||||||
` : "";
|
` : "";
|
||||||
|
|
||||||
return `PRODUIT: ${p.libelle1} (${p.codein})
|
return `PRODUIT : ${p.libelle1} (${p.codein})
|
||||||
PERFORMANCE: Score Global ${p.score.toFixed(1)}/100
|
Famille / Rayon : ${p.libelleNiveau2}
|
||||||
KPIs: CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo}
|
Score Algorithmique : ${p.score.toFixed(1)}/100
|
||||||
Verdict purement algorithmique: ${p.scoring?.decision || "Non calculé"}${contextRules}
|
KPIs : CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo}
|
||||||
Ta tâche : Donne la recommandation finale (A, C ou Z) et justifie-la factuellement.`;
|
Verdict purement algorithmique : ${p.scoring?.decision || "Non calculé"}${contextRules}
|
||||||
|
|
||||||
|
Génère UNIQUEMENT le JSON :`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static extractRecommendation(content: string): "A" | "C" | "Z" | null {
|
static extractRecommendation(content: string): "A" | "C" | "Z" | null {
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ export class OpenRouterClient {
|
|||||||
{ role: "system", content: AnalysisEngine.generateSystemPrompt() },
|
{ role: "system", content: AnalysisEngine.generateSystemPrompt() },
|
||||||
{ role: "user", content: AnalysisEngine.generateUserMessage(p) },
|
{ role: "user", content: AnalysisEngine.generateUserMessage(p) },
|
||||||
],
|
],
|
||||||
max_tokens: 150,
|
response_format: { type: "json_object" },
|
||||||
temperature: 0.3,
|
max_tokens: 200,
|
||||||
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,8 +43,29 @@ export class OpenRouterClient {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const content = data.choices?.[0]?.message?.content ?? "";
|
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 {
|
return {
|
||||||
insight: reco ? `[${reco}] ${cleanInsight}` : cleanInsight,
|
insight: reco ? `[${reco}] ${cleanInsight}` : cleanInsight,
|
||||||
|
|||||||
Reference in new issue
Block a user