seo-generator-server/lib/validation/CriteriaEvaluator.js
StillHammer 9a2ef7da2b feat(human-simulation): Système d'erreurs graduées procédurales + anti-répétition complet
## 🎯 Nouveau système d'erreurs graduées (architecture SmartTouch)

### Architecture procédurale intelligente :
- **3 niveaux de gravité** : Légère (50%) → Moyenne (30%) → Grave (10%)
- **14 types d'erreurs** réalistes et subtiles
- **Sélection procédurale** selon contexte (longueur, technique, heure)
- **Distribution contrôlée** : max 1 grave, 2 moyennes, 3 légères par article

### 1. Erreurs GRAVES (10% articles max) :
- Accord sujet-verbe : "ils sont" → "ils est"
- Mot manquant : "pour garantir la qualité" → "pour garantir qualité"
- Double mot : "pour garantir" → "pour pour garantir"
- Négation oubliée : "n'est pas" → "est pas"

### 2. Erreurs MOYENNES (30% articles) :
- Accord pluriel : "plaques résistantes" → "plaques résistant"
- Virgule manquante : "Ainsi, il" → "Ainsi il"
- Registre inapproprié : "Par conséquent" → "Du coup"
- Préposition incorrecte : "résistant aux" → "résistant des"
- Connecteur illogique : "cependant" → "donc"

### 3. Erreurs LÉGÈRES (50% articles) :
- Double espace : "de votre" → "de  votre"
- Trait d'union : "c'est-à-dire" → "c'est à dire"
- Espace ponctuation : "qualité ?" → "qualité?"
- Majuscule : "Toutenplaque" → "toutenplaque"
- Apostrophe droite : "l'article" → "l'article"

##  Système anti-répétition complet :

### Corrections critiques :
- **HumanSimulationTracker.js** : Tracker centralisé global
- **Word boundaries (\b)** sur TOUS les regex → FIX "maison" → "néanmoinson"
- **Protection 30+ expressions idiomatiques** françaises
- **Anti-répétition** : max 2× même mot, jamais 2× même développement
- **Diversification** : 48 variantes (hésitations, développements, connecteurs)

### Nouvelle structure (comme SmartTouch) :
```
lib/human-simulation/
├── error-profiles/                (NOUVEAU)
│   ├── ErrorProfiles.js          (définitions + probabilités)
│   ├── ErrorGrave.js             (10% articles)
│   ├── ErrorMoyenne.js           (30% articles)
│   ├── ErrorLegere.js            (50% articles)
│   └── ErrorSelector.js          (sélection procédurale)
├── HumanSimulationCore.js         (orchestrateur)
├── HumanSimulationTracker.js      (anti-répétition)
└── [autres modules]
```

## 🔄 Remplace ancien système :
-  SpellingErrors.js (basique, répétitif, "et" → "." × 8)
-  error-profiles/ (gradué, procédural, intelligent, diversifié)

## 🎲 Fonctionnalités procédurales :
- Analyse contexte : longueur texte, complexité technique, heure rédaction
- Multiplicateurs adaptatifs selon contexte
- Conditions application intelligentes
- Tracking global par batch (respecte limites 10%/30%/50%)

## 📊 Résultats validation :
Sur 100 articles → ~40-50 avec erreurs subtiles et diverses (plus de spam répétitif)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 01:06:28 +08:00

478 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };