// ======================================== // MÉTRIQUES DE QUALITÉ - VALIDATION OBJECTIVE // Calculs automatisés de qualité textuelle // ======================================== // Import du LLMManager pour validation IA complémentaire let LLMManager; try { const llmModule = require('../../lib/LLMManager'); if (llmModule && typeof llmModule.callLLM === 'function') { LLMManager = llmModule; } } catch (error) { console.warn('LLMManager non disponible pour QualityMetrics:', error.message); } /** * Système de métriques objectifs pour évaluer la qualité du contenu */ class QualityMetrics { /** * Calcul complet des métriques de qualité */ static async calculateMetrics(content, options = {}) { // Métriques objectives (calculs mathématiques) const objectiveMetrics = { readability: this.calculateReadability(content), vocabulary: this.calculateVocabularyRichness(content), structure: this.calculateStructureMetrics(content), coherence: this.calculateCoherenceMetrics(content), seo: this.calculateSEOMetrics(content), overall: 0 }; // Score global pondéré objectif objectiveMetrics.overall = Math.round( objectiveMetrics.readability * 0.25 + objectiveMetrics.vocabulary * 0.25 + objectiveMetrics.structure * 0.25 + objectiveMetrics.coherence * 0.25 ); // Validation IA complémentaire (si LLM disponible) if (LLMManager && options.enableLLM !== false) { try { const aiValidation = await this.validateQualityWithLLM(content, objectiveMetrics); // Fusion des métriques (privilégier les calculs objectifs) const enhancedMetrics = { ...objectiveMetrics, aiInsights: aiValidation, // Score final: 80% objectif + 20% IA overall: Math.round(objectiveMetrics.overall * 0.8 + aiValidation.score * 0.2) }; return enhancedMetrics; } catch (error) { console.warn('Erreur validation LLM qualité:', error.message); } } return objectiveMetrics; } /** * Validation LLM complémentaire des métriques */ static async validateQualityWithLLM(content, objectiveMetrics) { const prompt = ` MISSION: Valide la qualité de ce contenu et confirme/ajuste les métriques objectives. MÉTRIQUES OBJECTIVES CALCULÉES: - Lisibilité: ${objectiveMetrics.readability}/100 - Vocabulaire: ${objectiveMetrics.vocabulary}/100 - Structure: ${objectiveMetrics.structure}/100 - Cohérence: ${objectiveMetrics.coherence}/100 - Score global: ${objectiveMetrics.overall}/100 CONTENU À ANALYSER: --- ${content.substring(0, 1000)}${content.length > 1000 ? '...[TRONQUÉ]' : ''} --- VALIDATION QUALITATIVE: 1. FLUIDITÉ: Le texte se lit-il naturellement? 2. CLARTÉ: Les idées sont-elles bien exprimées? 3. ENGAGEMENT: Le contenu est-il intéressant? 4. PROFESSIONNALISME: Niveau de qualité rédactionnelle? RÉPONSE JSON STRICTE: { "score": 75, "validation": "confirmed", "qualitativeAssessment": { "fluidity": 80, "clarity": 75, "engagement": 70, "professionalism": 85 }, "feedback": "Analyse qualitative en 2-3 phrases", "confidence": 0.9 } SCORE: 0-100 (qualité globale perçue par un lecteur)`; const response = await LLMManager.callLLM('openai', prompt, { temperature: 0.1, max_tokens: 300 }); try { let jsonStr = response.trim(); const jsonMatch = jsonStr.match(/\{[\s\S]*\}/); if (jsonMatch) { jsonStr = jsonMatch[0]; } const parsed = JSON.parse(jsonStr); return { score: Math.max(0, Math.min(100, parsed.score || 50)), validation: parsed.validation || 'partial', qualitative: parsed.qualitativeAssessment || {}, feedback: parsed.feedback || 'Analyse non disponible', confidence: Math.max(0, Math.min(1, parsed.confidence || 0.5)), source: 'llm' }; } catch (error) { console.warn('Erreur parsing réponse LLM qualité:', error.message); return { score: objectiveMetrics.overall, // Fallback sur score objectif feedback: `Erreur parsing: ${error.message}`, confidence: 0.1, source: 'fallback' }; } } /** * Métriques de lisibilité (basées sur Flesch-Kincaid adapté au français) */ static calculateReadability(content) { const sentences = this.countSentences(content); const words = this.countWords(content); const syllables = this.estimateSyllables(content); if (sentences === 0 || words === 0) return 0; // Adaptation française de Flesch-Kincaid const avgWordsPerSentence = words / sentences; const avgSyllablesPerWord = syllables / words; // Formule adaptée pour le français let readabilityScore = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord); // Normalisation 0-100 readabilityScore = Math.max(0, Math.min(100, readabilityScore)); return Math.round(readabilityScore); } /** * Richesse du vocabulaire */ static calculateVocabularyRichness(content) { const words = this.getWords(content); const uniqueWords = new Set(words.map(w => w.toLowerCase())); if (words.length === 0) return 0; // Ratio mots uniques / mots totaux const uniquenessRatio = uniqueWords.size / words.length; // Bonus pour la longueur des mots const avgWordLength = words.reduce((sum, word) => sum + word.length, 0) / words.length; const lengthBonus = Math.min((avgWordLength - 3) * 10, 20); // Max 20 points bonus // Pénalité pour répétitions excessives const repetitionPenalty = this.calculateRepetitionPenalty(words); let richness = (uniquenessRatio * 80) + lengthBonus - repetitionPenalty; richness = Math.max(0, Math.min(100, richness)); return Math.round(richness); } /** * Métriques de structure */ static calculateStructureMetrics(content) { let score = 100; // Vérification paragraphes const paragraphs = content.split('\n\n').filter(p => p.trim().length > 0); if (paragraphs.length < 2) score -= 20; // Longueur des paragraphes const avgParagraphLength = paragraphs.reduce((sum, p) => sum + p.length, 0) / paragraphs.length; if (avgParagraphLength > 800) score -= 15; // Paragraphes trop longs if (avgParagraphLength < 100) score -= 10; // Paragraphes trop courts // Présence de listes ou énumérations const hasLists = /[-•*]\s/.test(content) || /\d+\.\s/.test(content); if (hasLists) score += 5; // Variété dans la longueur des phrases const sentences = this.getSentences(content); const sentenceLengthVariation = this.calculateVariation(sentences.map(s => s.length)); score += Math.min(sentenceLengthVariation * 10, 15); return Math.max(0, Math.min(100, Math.round(score))); } /** * Métriques de cohérence (approximation basée sur mots de liaison) */ static calculateCoherenceMetrics(content) { const words = this.getWords(content); const sentences = this.getSentences(content); if (sentences.length < 2) return 50; // Mots de liaison français const connectors = [ 'donc', 'ainsi', 'par conséquent', 'en effet', 'cependant', 'néanmoins', 'toutefois', 'd\'ailleurs', 'en outre', 'de plus', 'également', 'aussi', 'enfin', 'finalement', 'en conclusion', 'premièrement', 'deuxièmement', 'mais', 'or', 'car', 'parce que', 'puisque', 'comme', 'si', 'bien que' ]; // Comptage des connecteurs const connectorCount = words.filter(word => connectors.includes(word.toLowerCase()) ).length; const connectorRatio = connectorCount / sentences.length; // Score basé sur la densité de connecteurs (optimum autour de 0.2-0.4) let coherenceScore; if (connectorRatio >= 0.2 && connectorRatio <= 0.4) { coherenceScore = 100; } else if (connectorRatio < 0.2) { coherenceScore = 50 + (connectorRatio * 250); // Manque de connecteurs } else { coherenceScore = 100 - ((connectorRatio - 0.4) * 100); // Trop de connecteurs } return Math.max(0, Math.min(100, Math.round(coherenceScore))); } /** * Métriques SEO basiques */ static calculateSEOMetrics(content) { let score = 100; // Longueur du contenu const wordCount = this.countWords(content); if (wordCount < 300) score -= 30; else if (wordCount < 500) score -= 15; else if (wordCount > 2000) score -= 10; // Présence de titres (simulation) const hasTitles = //.test(content) || /^#{1,6}\s/.test(content); if (!hasTitles) score -= 20; // Densité des mots-clés (éviter la suroptimisation) const keywordDensity = this.estimateKeywordDensity(content); if (keywordDensity > 0.05) score -= 15; // Plus de 5% = suroptimisation return Math.max(0, Math.min(100, Math.round(score))); } /** * Utilitaires de comptage */ static countSentences(content) { return content.split(/[.!?]+/).filter(s => s.trim().length > 0).length; } static countWords(content) { return this.getWords(content).length; } static getWords(content) { return content .toLowerCase() .replace(/[^\w\s-]/g, ' ') .split(/\s+/) .filter(word => word.length > 0); } static getSentences(content) { return content.split(/[.!?]+/).filter(s => s.trim().length > 0); } /** * Estimation du nombre de syllabes (approximation française) */ static estimateSyllables(content) { const words = this.getWords(content); let totalSyllables = 0; words.forEach(word => { // Approximation simple pour le français let syllables = word.match(/[aeiouyàâäéèêëïîôöùûü]/gi); syllables = syllables ? syllables.length : 1; // Ajustements pour le français if (word.endsWith('e') && syllables > 1) syllables--; if (word.includes('eau') || word.includes('oui')) syllables--; totalSyllables += Math.max(1, syllables); }); return totalSyllables; } /** * Calcul de la pénalité pour répétitions */ static calculateRepetitionPenalty(words) { const wordCount = {}; words.forEach(word => { const lower = word.toLowerCase(); if (lower.length > 3) { // Ignorer les mots trop courts wordCount[lower] = (wordCount[lower] || 0) + 1; } }); let penalty = 0; Object.values(wordCount).forEach(count => { if (count > 3) { penalty += (count - 3) * 2; // 2 points de pénalité par répétition excessive } }); return Math.min(penalty, 30); // Max 30 points de pénalité } /** * Calcul de variation (écart-type simplifié) */ static calculateVariation(numbers) { if (numbers.length < 2) return 0; const avg = numbers.reduce((sum, n) => sum + n, 0) / numbers.length; const variance = numbers.reduce((sum, n) => sum + Math.pow(n - avg, 2), 0) / numbers.length; return Math.sqrt(variance) / avg; // Coefficient de variation } /** * Estimation basique de la densité des mots-clés */ static estimateKeywordDensity(content) { const words = this.getWords(content); const totalWords = words.length; if (totalWords === 0) return 0; // Recherche de répétitions potentielles de mots-clés const wordFreq = {}; words.forEach(word => { if (word.length > 4) { // Mots significatifs wordFreq[word] = (wordFreq[word] || 0) + 1; } }); // Trouver le mot le plus répété (potentiel mot-clé) const maxFreq = Math.max(...Object.values(wordFreq)); return maxFreq / totalWords; } /** * Analyse rapide pour dashboard */ static quickAnalysis(content) { const words = this.countWords(content); const sentences = this.countSentences(content); const readability = this.calculateReadability(content); return { wordCount: words, sentenceCount: sentences, avgWordsPerSentence: sentences > 0 ? Math.round(words / sentences) : 0, readabilityScore: readability, readabilityLevel: this.getReadabilityLevel(readability) }; } /** * Niveau de lisibilité textuel */ static getReadabilityLevel(score) { if (score >= 90) return 'Très facile'; if (score >= 80) return 'Facile'; if (score >= 70) return 'Assez facile'; if (score >= 60) return 'Standard'; if (score >= 50) return 'Assez difficile'; if (score >= 30) return 'Difficile'; return 'Très difficile'; } } module.exports = { QualityMetrics };