seo-generator-server/tests/validators/QualityMetrics.js

410 lines
13 KiB
JavaScript

// ========================================
// 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 = /<h[1-6]>/.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 };