/** * CriteriaEvaluator.js * * Évaluateur multi-critères pour Pipeline Validator * Évalue la qualité du contenu via LLM selon 5 critères universels */ const { logSh } = require('../ErrorReporting'); const { tracer } = require('../trace'); const { callLLM } = require('../LLMManager'); /** * Définition des 5 critères universels */ const CRITERIA = { qualite: { id: 'qualite', name: 'Qualité globale', description: 'Grammaire, orthographe, syntaxe, cohérence et pertinence contextuelle', weight: 1.0 }, verbosite: { id: 'verbosite', name: 'Verbosité / Concision', description: 'Densité informationnelle, longueur appropriée, absence de fluff', weight: 1.0 }, seo: { id: 'seo', name: 'SEO et mots-clés', description: 'Intégration naturelle des mots-clés, structure SEO-friendly', weight: 1.0 }, repetitions: { id: 'repetitions', name: 'Répétitions et variations', description: 'Variété lexicale, évite répétitions, usage synonymes', weight: 1.0 }, naturalite: { id: 'naturalite', name: 'Naturalité humaine', description: 'Semble écrit par un humain, évite patterns IA', weight: 1.5 // Critère le plus important pour SEO anti-détection } }; /** * Classe CriteriaEvaluator */ class CriteriaEvaluator { constructor() { this.defaultLLM = 'claude-sonnet-4-5'; // Claude pour objectivité this.temperature = 0.3; // Cohérence entre évaluations this.maxRetries = 2; this.evaluationCache = {}; // Cache pour éviter réévaluations inutiles } /** * Évalue un échantillon selon tous les critères à travers toutes les versions * @param {Object} sample - Échantillon avec versions * @param {Object} context - Contexte (MC0, T0, personality) * @param {Array} criteriaFilter - ✅ NOUVEAU: Liste des critères à évaluer (optionnel) * @returns {Object} - Évaluations par critère et version */ async evaluateSample(sample, context, criteriaFilter = null) { return tracer.run('CriteriaEvaluator.evaluateSample', async () => { logSh(`🎯 Évaluation échantillon: ${sample.tag} (${sample.type})`, 'INFO'); const evaluations = {}; const versionNames = Object.keys(sample.versions); // ✅ Filtrer critères si spécifié const criteriaIds = criteriaFilter && criteriaFilter.length > 0 ? criteriaFilter.filter(id => CRITERIA[id]) // Valider que le critère existe : Object.keys(CRITERIA); // Pour chaque critère for (const criteriaId of criteriaIds) { const criteria = CRITERIA[criteriaId]; evaluations[criteriaId] = {}; logSh(` 📊 Critère: ${criteria.name}`, 'DEBUG'); // Pour chaque version for (const versionName of versionNames) { const text = sample.versions[versionName]; // Skip si non disponible if (text === "[Non disponible à cette étape]" || text === "[Erreur lecture]") { evaluations[criteriaId][versionName] = { score: null, reasoning: "Contenu non disponible à cette étape", skipped: true }; continue; } try { // Évaluer avec retry const evaluation = await this.evaluateWithRetry( text, criteria, sample.type, context, versionName ); evaluations[criteriaId][versionName] = evaluation; logSh(` ✓ ${versionName}: ${evaluation.score}/10`, 'DEBUG'); } catch (error) { logSh(` ❌ ${versionName}: ${error.message}`, 'ERROR'); evaluations[criteriaId][versionName] = { score: null, reasoning: `Erreur évaluation: ${error.message}`, error: true }; } } } logSh(` ✅ Échantillon évalué: ${Object.keys(CRITERIA).length} critères × ${versionNames.length} versions`, 'INFO'); return evaluations; }, { tag: sample.tag, type: sample.type }); } /** * Évalue avec retry logic */ async evaluateWithRetry(text, criteria, type, context, versionName) { let lastError; for (let attempt = 0; attempt <= this.maxRetries; attempt++) { try { if (attempt > 0) { logSh(` 🔄 Retry ${attempt}/${this.maxRetries}...`, 'DEBUG'); } return await this.evaluate(text, criteria, type, context); } catch (error) { lastError = error; if (attempt < this.maxRetries) { // Attendre avant retry (exponential backoff) await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); } } } throw lastError; } /** * Évalue un texte selon un critère */ async evaluate(text, criteria, type, context) { const prompt = this.buildPrompt(text, criteria, type, context); // Appel LLM const response = await callLLM( this.defaultLLM, prompt, this.temperature, 4000 // max tokens ); // Parser la réponse JSON const evaluation = this.parseEvaluation(response); // Valider this.validateEvaluation(evaluation); return evaluation; } /** * Construit le prompt d'évaluation structuré */ buildPrompt(text, criteria, type, context) { const { mc0 = '', t0 = '', personality = {} } = context; // Texte tronqué si trop long (max 2000 chars pour contexte) const truncatedText = text.length > 2000 ? text.substring(0, 2000) + '... [tronqué]' : text; return `Tu es un évaluateur objectif de contenu SEO. CONTEXTE: - Mot-clé principal: ${mc0} - Thématique: ${t0} - Personnalité: ${personality.nom || 'Non spécifiée'} - Type de contenu: ${type} (title/content/faq) ÉLÉMENT À ÉVALUER: "${truncatedText}" CRITÈRE: ${criteria.name} Description: ${criteria.description} ${this.getCriteriaPromptDetails(criteria.id, type)} TÂCHE: Évalue cet élément selon le critère ci-dessus. Donne une note de 0 à 10 (précision: 0.5). Justifie ta notation en 2-3 phrases concrètes. RÉPONSE ATTENDUE (JSON strict): { "score": 7.5, "reasoning": "Justification détaillée en 2-3 phrases..." }`; } /** * Obtient les détails spécifiques d'un critère */ getCriteriaPromptDetails(criteriaId, type) { const details = { qualite: `ÉCHELLE: 10 = Qualité exceptionnelle, aucune faute 7-9 = Bonne qualité, légères imperfections 4-6 = Qualité moyenne, plusieurs problèmes 1-3 = Faible qualité, nombreuses erreurs 0 = Inutilisable Évalue: - Grammaire et syntaxe impeccables ? - Texte fluide et cohérent ? - Pertinent par rapport au mot-clé "${this.context?.mc0 || 'principal'}" ?`, verbosite: `ÉCHELLE: 10 = Parfaitement concis, chaque mot compte 7-9 = Plutôt concis, peu de superflu 4-6 = Moyennement verbeux, du remplissage 1-3 = Très verbeux, beaucoup de fluff 0 = Délayage excessif Évalue: - Densité informationnelle élevée (info utile / longueur totale) ? - Longueur appropriée pour un ${type} (ni trop court, ni verbeux) ? - Absence de fluff et remplissage inutile ?`, seo: `ÉCHELLE: 10 = SEO optimal et naturel 7-9 = Bon SEO, quelques améliorations possibles 4-6 = SEO moyen, manque d'optimisation ou sur-optimisé 1-3 = SEO faible ou contre-productif 0 = Aucune considération SEO Évalue: - Mots-clés (notamment "${this.context?.mc0 || 'principal'}") intégrés naturellement ? - Densité appropriée (ni trop faible, ni keyword stuffing) ? - Structure SEO-friendly ?`, repetitions: `ÉCHELLE: 10 = Très varié, aucune répétition notable 7-9 = Plutôt varié, quelques répétitions mineures 4-6 = Variété moyenne, répétitions visibles 1-3 = Très répétitif, vocabulaire pauvre 0 = Répétitions excessives Évalue: - Répétitions de mots/expressions évitées ? - Vocabulaire varié et riche ? - Paraphrases et synonymes utilisés intelligemment ?`, naturalite: `ÉCHELLE: 10 = 100% indétectable, parfaitement humain 7-9 = Très naturel, légères traces IA 4-6 = Moyennement naturel, patterns IA visibles 1-3 = Clairement IA, très artificiel 0 = Robotique et détectable immédiatement Évalue: - Semble-t-il rédigé par un humain authentique ? - Présence de variations naturelles et imperfections réalistes ? - Absence de patterns IA typiques (phrases trop parfaites, formules creuses, superlatifs excessifs) ?` }; return details[criteriaId] || ''; } /** * Parse la réponse LLM en JSON */ parseEvaluation(response) { try { // Nettoyer la réponse (enlever markdown si présent) let cleaned = response.trim(); // Si la réponse contient des backticks, extraire le JSON if (cleaned.includes('```')) { const match = cleaned.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); if (match) { cleaned = match[1]; } } // Parser JSON const parsed = JSON.parse(cleaned); return { score: parsed.score, reasoning: parsed.reasoning }; } catch (error) { logSh(`❌ Erreur parsing JSON: ${error.message}`, 'ERROR'); logSh(` Réponse brute: ${response.substring(0, 200)}...`, 'DEBUG'); // Fallback: extraire score et reasoning par regex return this.fallbackParse(response); } } /** * Parsing fallback si JSON invalide */ fallbackParse(response) { // Chercher score avec regex const scoreMatch = response.match(/(?:score|note)[:\s]*([0-9]+(?:\.[0-9]+)?)/i); const score = scoreMatch ? parseFloat(scoreMatch[1]) : null; // Chercher reasoning const reasoningMatch = response.match(/(?:reasoning|justification)[:\s]*"?([^"]+)"?/i); const reasoning = reasoningMatch ? reasoningMatch[1].trim() : response.substring(0, 200); logSh(`⚠️ Fallback parsing: score=${score}, reasoning=${reasoning.substring(0, 50)}...`, 'WARN'); return { score, reasoning }; } /** * Valide une évaluation */ validateEvaluation(evaluation) { if (evaluation.score === null || evaluation.score === undefined) { throw new Error('Score manquant dans évaluation'); } if (evaluation.score < 0 || evaluation.score > 10) { throw new Error(`Score invalide: ${evaluation.score} (doit être entre 0 et 10)`); } if (!evaluation.reasoning || evaluation.reasoning.length < 10) { throw new Error('Reasoning manquant ou trop court'); } } /** * Évalue plusieurs échantillons en parallèle (avec limite de concurrence) * @param {Object} samples - Échantillons à évaluer * @param {Object} context - Contexte * @param {number} maxConcurrent - Limite concurrence * @param {Array} criteriaFilter - ✅ NOUVEAU: Filtrer critères (optionnel) */ async evaluateBatch(samples, context, maxConcurrent = 3, criteriaFilter = null) { return tracer.run('CriteriaEvaluator.evaluateBatch', async () => { const criteriaInfo = criteriaFilter && criteriaFilter.length > 0 ? criteriaFilter.join(', ') : 'tous critères'; logSh(`🎯 Évaluation batch: ${Object.keys(samples).length} échantillons (concurrence: ${maxConcurrent}, critères: ${criteriaInfo})`, 'INFO'); const results = {}; const sampleEntries = Object.entries(samples); // Traiter par batch pour limiter concurrence for (let i = 0; i < sampleEntries.length; i += maxConcurrent) { const batch = sampleEntries.slice(i, i + maxConcurrent); logSh(` 📦 Batch ${Math.floor(i / maxConcurrent) + 1}/${Math.ceil(sampleEntries.length / maxConcurrent)}: ${batch.length} échantillons`, 'INFO'); // Évaluer en parallèle dans le batch const batchPromises = batch.map(async ([tag, sample]) => { const evaluations = await this.evaluateSample(sample, context, criteriaFilter); // ✅ Passer filtre return [tag, evaluations]; }); const batchResults = await Promise.all(batchPromises); // Ajouter aux résultats batchResults.forEach(([tag, evaluations]) => { results[tag] = evaluations; }); } logSh(`✅ Batch évaluation terminée: ${Object.keys(results).length} échantillons évalués`, 'INFO'); return results; }, { samplesCount: Object.keys(samples).length, maxConcurrent }); } /** * Calcule les scores moyens par version */ aggregateScores(evaluations) { const aggregated = { byVersion: {}, byCriteria: {}, overall: { avgScore: 0, totalEvaluations: 0 } }; // Collecter tous les scores par version const versionScores = {}; const criteriaScores = {}; for (const [tag, sampleEvals] of Object.entries(evaluations)) { for (const [criteriaId, versionEvals] of Object.entries(sampleEvals)) { if (!criteriaScores[criteriaId]) { criteriaScores[criteriaId] = []; } for (const [versionName, evaluation] of Object.entries(versionEvals)) { if (evaluation.score !== null && !evaluation.skipped && !evaluation.error) { if (!versionScores[versionName]) { versionScores[versionName] = []; } versionScores[versionName].push(evaluation.score); criteriaScores[criteriaId].push(evaluation.score); } } } } // Calculer moyennes par version for (const [versionName, scores] of Object.entries(versionScores)) { const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length; aggregated.byVersion[versionName] = { avgScore: Math.round(avg * 10) / 10, count: scores.length }; } // Calculer moyennes par critère for (const [criteriaId, scores] of Object.entries(criteriaScores)) { const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length; aggregated.byCriteria[criteriaId] = { avgScore: Math.round(avg * 10) / 10, count: scores.length }; } // Calculer moyenne globale const allScores = Object.values(versionScores).flat(); if (allScores.length > 0) { aggregated.overall.avgScore = Math.round((allScores.reduce((sum, s) => sum + s, 0) / allScores.length) * 10) / 10; aggregated.overall.totalEvaluations = allScores.length; } return aggregated; } /** * Obtient les critères disponibles */ static getCriteria() { return CRITERIA; } /** * Reset le cache */ resetCache() { this.evaluationCache = {}; } } module.exports = { CriteriaEvaluator, CRITERIA };