## 🎯 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>
478 lines
14 KiB
JavaScript
478 lines
14 KiB
JavaScript
/**
|
||
* 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 };
|