seo-generator-server/lib/selective-enhancement/SelectiveUtils.js
StillHammer 602d06ba21 feat: Améliore système de génération avec personnalités enrichies et détection intelligente
Ajouts majeurs:
- Détection intelligente des contraintes de longueur dans les instructions (évite conflits entre instruction et type)
- Contexte de personnalité enrichi: colonnes B, D, E, F, H, I, J du Google Sheet
- Randomisation des éléments de personnalité (max 2 par catégorie pour variabilité)
- Injection du titre associé dans le prompt des textes/intros pour cohérence
- Exclusion de personnalité pour questions FAQ (seulement réponses)

Corrections:
- Fix détection type: vérification préfixes (Intro_, Titre_, Txt_) avant suffixes (_title, _text)
- Fix parsing vocabulairePref pour gérer arrays et strings
- Logs détaillés sur détection de contraintes

Impact:
- Meilleure cohérence titre→texte
- Plus de variabilité anti-détection
- Respect strict des contraintes de longueur spécifiées
- Personnalités mieux contextualisées dans les prompts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 10:36:59 +08:00

1209 lines
41 KiB
JavaScript

// ========================================
// SELECTIVE UTILS - UTILITAIRES MODULAIRES
// Responsabilité: Fonctions utilitaires partagées par tous les modules selective
// Architecture: Helper functions réutilisables et composables
// ========================================
const { logSh } = require('../ErrorReporting');
/**
* ANALYSEURS DE CONTENU SELECTIVE
*/
/**
* Analyser qualité technique d'un contenu
*/
function analyzeTechnicalQuality(content, contextualTerms = []) {
if (!content || typeof content !== 'string') return { score: 0, details: {} };
const analysis = {
score: 0,
details: {
technicalTermsFound: 0,
technicalTermsExpected: contextualTerms.length,
genericWordsCount: 0,
hasSpecifications: false,
hasDimensions: false,
contextIntegration: 0
}
};
const lowerContent = content.toLowerCase();
// 1. Compter termes techniques présents
contextualTerms.forEach(term => {
if (lowerContent.includes(term.toLowerCase())) {
analysis.details.technicalTermsFound++;
}
});
// 2. Détecter mots génériques
const genericWords = ['produit', 'solution', 'service', 'offre', 'article', 'élément'];
analysis.details.genericWordsCount = genericWords.filter(word =>
lowerContent.includes(word)
).length;
// 3. Vérifier spécifications techniques
analysis.details.hasSpecifications = /\b(norme|iso|din|ce)\b/i.test(content);
// 4. Vérifier dimensions/données techniques
analysis.details.hasDimensions = /\d+\s*(mm|cm|m|%|°|kg|g)\b/i.test(content);
// 5. Calculer score global (0-100)
const termRatio = contextualTerms.length > 0 ?
(analysis.details.technicalTermsFound / contextualTerms.length) * 40 : 20;
const genericPenalty = Math.min(20, analysis.details.genericWordsCount * 5);
const specificationBonus = analysis.details.hasSpecifications ? 15 : 0;
const dimensionBonus = analysis.details.hasDimensions ? 15 : 0;
const lengthBonus = content.length > 100 ? 10 : 0;
analysis.score = Math.max(0, Math.min(100,
termRatio + specificationBonus + dimensionBonus + lengthBonus - genericPenalty
));
return analysis;
}
/**
* Analyser fluidité des transitions
*/
function analyzeTransitionFluidity(content) {
if (!content || typeof content !== 'string') return { score: 0, details: {} };
const sentences = content.split(/[.!?]+/)
.map(s => s.trim())
.filter(s => s.length > 5);
if (sentences.length < 2) {
return { score: 100, details: { reason: 'Contenu trop court pour analyse transitions' } };
}
const analysis = {
score: 0,
details: {
sentencesCount: sentences.length,
connectorsFound: 0,
repetitiveConnectors: 0,
abruptTransitions: 0,
averageSentenceLength: 0,
lengthVariation: 0
}
};
// 1. Analyser connecteurs
const commonConnectors = ['par ailleurs', 'en effet', 'de plus', 'cependant', 'ainsi', 'donc', 'ensuite'];
const connectorCounts = {};
commonConnectors.forEach(connector => {
const matches = (content.match(new RegExp(`\\b${connector}\\b`, 'gi')) || []);
connectorCounts[connector] = matches.length;
analysis.details.connectorsFound += matches.length;
if (matches.length > 1) analysis.details.repetitiveConnectors++;
});
// 2. Détecter transitions abruptes
for (let i = 1; i < sentences.length; i++) {
const sentence = sentences[i].toLowerCase().trim();
const hasConnector = commonConnectors.some(connector =>
sentence.startsWith(connector) || sentence.includes(` ${connector} `)
);
if (!hasConnector && sentence.length > 20) {
analysis.details.abruptTransitions++;
}
}
// 3. Analyser variation de longueur
const lengths = sentences.map(s => s.split(/\s+/).length);
analysis.details.averageSentenceLength = lengths.reduce((a, b) => a + b, 0) / lengths.length;
const variance = lengths.reduce((acc, len) =>
acc + Math.pow(len - analysis.details.averageSentenceLength, 2), 0
) / lengths.length;
analysis.details.lengthVariation = Math.sqrt(variance);
// 4. Calculer score fluidité (0-100)
const connectorScore = Math.min(30, (analysis.details.connectorsFound / sentences.length) * 100);
const repetitionPenalty = Math.min(20, analysis.details.repetitiveConnectors * 5);
const abruptPenalty = Math.min(30, (analysis.details.abruptTransitions / sentences.length) * 50);
const variationScore = Math.min(20, analysis.details.lengthVariation * 2);
analysis.score = Math.max(0, Math.min(100,
connectorScore + variationScore - repetitionPenalty - abruptPenalty + 50
));
return analysis;
}
/**
* Analyser cohérence de style
*/
function analyzeStyleConsistency(content, expectedPersonality = null) {
if (!content || typeof content !== 'string') return { score: 0, details: {} };
const analysis = {
score: 0,
details: {
personalityAlignment: 0,
toneConsistency: 0,
vocabularyLevel: 'standard',
formalityScore: 0,
personalityWordsFound: 0
}
};
// 1. Analyser alignement personnalité
if (expectedPersonality && expectedPersonality.vocabulairePref) {
// Convertir en string si ce n'est pas déjà le cas
const vocabPref = typeof expectedPersonality.vocabulairePref === 'string'
? expectedPersonality.vocabulairePref
: String(expectedPersonality.vocabulairePref);
const personalityWords = vocabPref.toLowerCase().split(',');
const contentLower = content.toLowerCase();
personalityWords.forEach(word => {
if (word.trim() && contentLower.includes(word.trim())) {
analysis.details.personalityWordsFound++;
}
});
analysis.details.personalityAlignment = personalityWords.length > 0 ?
(analysis.details.personalityWordsFound / personalityWords.length) * 100 : 0;
}
// 2. Analyser niveau vocabulaire
const technicalWords = content.match(/\b\w{8,}\b/g) || [];
const totalWords = content.split(/\s+/).length;
const techRatio = technicalWords.length / totalWords;
if (techRatio > 0.15) analysis.details.vocabularyLevel = 'expert';
else if (techRatio < 0.05) analysis.details.vocabularyLevel = 'accessible';
else analysis.details.vocabularyLevel = 'standard';
// 3. Analyser formalité
const formalIndicators = ['il convient de', 'par conséquent', 'néanmoins', 'toutefois'];
const casualIndicators = ['du coup', 'sympa', 'cool', 'nickel'];
let formalCount = formalIndicators.filter(indicator =>
content.toLowerCase().includes(indicator)
).length;
let casualCount = casualIndicators.filter(indicator =>
content.toLowerCase().includes(indicator)
).length;
analysis.details.formalityScore = formalCount - casualCount; // Positif = formel, négatif = casual
// 4. Calculer score cohérence (0-100)
let baseScore = 50;
if (expectedPersonality) {
baseScore += analysis.details.personalityAlignment * 0.3;
// Ajustements selon niveau technique attendu
const expectedLevel = expectedPersonality.niveauTechnique || 'standard';
if (expectedLevel === analysis.details.vocabularyLevel) {
baseScore += 20;
} else {
baseScore -= 10;
}
}
// Bonus cohérence tonale
const sentences = content.split(/[.!?]+/).filter(s => s.length > 10);
if (sentences.length > 1) {
baseScore += Math.min(20, analysis.details.lengthVariation || 10);
}
analysis.score = Math.max(0, Math.min(100, baseScore));
return analysis;
}
/**
* COMPARATEURS ET MÉTRIQUES
*/
/**
* Comparer deux contenus et calculer taux amélioration
*/
function compareContentImprovement(original, enhanced, analysisType = 'general') {
if (!original || !enhanced) return { improvementRate: 0, details: {} };
const comparison = {
improvementRate: 0,
details: {
lengthChange: ((enhanced.length - original.length) / original.length) * 100,
wordCountChange: 0,
structuralChanges: 0,
contentPreserved: true
}
};
// 1. Analyser changements structurels
const originalSentences = original.split(/[.!?]+/).length;
const enhancedSentences = enhanced.split(/[.!?]+/).length;
comparison.details.structuralChanges = Math.abs(enhancedSentences - originalSentences);
// 2. Analyser changements de mots
const originalWords = original.toLowerCase().split(/\s+/).filter(w => w.length > 2);
const enhancedWords = enhanced.toLowerCase().split(/\s+/).filter(w => w.length > 2);
comparison.details.wordCountChange = enhancedWords.length - originalWords.length;
// 3. Vérifier préservation du contenu principal
const originalKeyWords = originalWords.filter(w => w.length > 4);
const preservedWords = originalKeyWords.filter(w => enhanced.toLowerCase().includes(w));
comparison.details.contentPreserved = (preservedWords.length / originalKeyWords.length) > 0.7;
// 4. Calculer taux amélioration selon type d'analyse
switch (analysisType) {
case 'technical':
const originalTech = analyzeTechnicalQuality(original);
const enhancedTech = analyzeTechnicalQuality(enhanced);
comparison.improvementRate = enhancedTech.score - originalTech.score;
break;
case 'transitions':
const originalFluid = analyzeTransitionFluidity(original);
const enhancedFluid = analyzeTransitionFluidity(enhanced);
comparison.improvementRate = enhancedFluid.score - originalFluid.score;
break;
case 'style':
const originalStyle = analyzeStyleConsistency(original);
const enhancedStyle = analyzeStyleConsistency(enhanced);
comparison.improvementRate = enhancedStyle.score - originalStyle.score;
break;
default:
// Amélioration générale (moyenne pondérée)
comparison.improvementRate = Math.min(50, Math.abs(comparison.details.lengthChange) * 0.1 +
(comparison.details.contentPreserved ? 20 : -20) +
Math.min(15, Math.abs(comparison.details.wordCountChange)));
}
return comparison;
}
/**
* UTILITAIRES DE CONTENU
*/
/**
* Nettoyer contenu généré par LLM
*/
function cleanGeneratedContent(content, cleaningLevel = 'standard') {
if (!content || typeof content !== 'string') return content;
let cleaned = content.trim();
// Nettoyage de base
cleaned = cleaned.replace(/^(voici\s+)?le\s+contenu\s+(amélioré|modifié|réécrit)[:\s]*/gi, '');
cleaned = cleaned.replace(/^(bon,?\s*)?(alors,?\s*)?(voici\s+)?/gi, '');
cleaned = cleaned.replace(/^(avec\s+les?\s+)?améliorations?\s*[:\s]*/gi, '');
// Nettoyage formatage
cleaned = cleaned.replace(/\*\*([^*]+)\*\*/g, '$1'); // Gras markdown → texte normal
cleaned = cleaned.replace(/\s{2,}/g, ' '); // Espaces multiples
cleaned = cleaned.replace(/([.!?])\s*([.!?])/g, '$1 '); // Double ponctuation
if (cleaningLevel === 'intensive') {
// Nettoyage intensif
cleaned = cleaned.replace(/^\s*[-*+]\s*/gm, ''); // Puces en début de ligne
cleaned = cleaned.replace(/^(pour\s+)?(ce\s+)?(contenu\s*)?[,:]?\s*/gi, '');
cleaned = cleaned.replace(/\([^)]*\)/g, ''); // Parenthèses et contenu
}
// Nettoyage final
cleaned = cleaned.replace(/^[,.\s]+/, ''); // Début
cleaned = cleaned.replace(/[,\s]+$/, ''); // Fin
cleaned = cleaned.trim();
return cleaned;
}
/**
* Valider contenu selective
*/
function validateSelectiveContent(content, originalContent, criteria = {}) {
const validation = {
isValid: true,
score: 0,
issues: [],
suggestions: []
};
const {
minLength = 20,
maxLengthChange = 50, // % de changement maximum
preserveContent = true,
checkTechnicalTerms = true
} = criteria;
// 1. Vérifier longueur
if (!content || content.length < minLength) {
validation.isValid = false;
validation.issues.push('Contenu trop court');
validation.suggestions.push('Augmenter la longueur du contenu généré');
} else {
validation.score += 25;
}
// 2. Vérifier changements de longueur
if (originalContent) {
const lengthChange = Math.abs((content.length - originalContent.length) / originalContent.length) * 100;
if (lengthChange > maxLengthChange) {
validation.issues.push('Changement de longueur excessif');
validation.suggestions.push('Réduire l\'intensité d\'amélioration');
} else {
validation.score += 25;
}
// 3. Vérifier préservation du contenu
if (preserveContent) {
const preservation = compareContentImprovement(originalContent, content);
if (!preservation.details.contentPreserved) {
validation.isValid = false;
validation.issues.push('Contenu original non préservé');
validation.suggestions.push('Améliorer conservation du sens original');
} else {
validation.score += 25;
}
}
}
// 4. Vérifications spécifiques
if (checkTechnicalTerms) {
const technicalQuality = analyzeTechnicalQuality(content);
if (technicalQuality.score > 60) {
validation.score += 25;
} else if (technicalQuality.score < 30) {
validation.issues.push('Qualité technique insuffisante');
validation.suggestions.push('Ajouter plus de termes techniques spécialisés');
}
}
// Score final et validation
validation.score = Math.min(100, validation.score);
validation.isValid = validation.isValid && validation.score >= 60;
return validation;
}
/**
* UTILITAIRES TECHNIQUES
*/
/**
* Chunk array avec gestion intelligente
*/
function chunkArray(array, chunkSize, smartChunking = false) {
if (!Array.isArray(array)) return [];
if (array.length <= chunkSize) return [array];
const chunks = [];
if (smartChunking) {
// Chunking intelligent : éviter de séparer éléments liés
let currentChunk = [];
for (let i = 0; i < array.length; i++) {
currentChunk.push(array[i]);
// Conditions de fin de chunk intelligente
const isChunkFull = currentChunk.length >= chunkSize;
const isLastElement = i === array.length - 1;
const nextElementRelated = i < array.length - 1 &&
array[i].tag && array[i + 1].tag &&
array[i].tag.includes('FAQ') && array[i + 1].tag.includes('FAQ');
if ((isChunkFull && !nextElementRelated) || isLastElement) {
chunks.push([...currentChunk]);
currentChunk = [];
}
}
// Ajouter chunk restant si non vide
if (currentChunk.length > 0) {
if (chunks.length > 0 && chunks[chunks.length - 1].length + currentChunk.length <= chunkSize * 1.2) {
// Merger avec dernier chunk si pas trop gros
chunks[chunks.length - 1].push(...currentChunk);
} else {
chunks.push(currentChunk);
}
}
} else {
// Chunking standard
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
}
return chunks;
}
/**
* Sleep avec logging optionnel
*/
async function sleep(ms, logMessage = null) {
if (logMessage) {
logSh(`${logMessage} (${ms}ms)`, 'DEBUG');
}
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Mesurer performance d'opération
*/
function measurePerformance(operationName, startTime = Date.now()) {
const endTime = Date.now();
const duration = endTime - startTime;
const performance = {
operationName,
startTime,
endTime,
duration,
durationFormatted: formatDuration(duration)
};
return performance;
}
/**
* Formater durée en format lisible
*/
function formatDuration(ms) {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
/**
* GÉNÉRATION SIMPLE (REMPLACE CONTENTGENERATION.JS)
*/
/**
* Détecter le type d'élément (legacy InitialGeneration.js logic)
* Retourne un string simple : 'titre', 'intro', 'paragraphe', 'faq_question', 'faq_reponse', 'conclusion'
*/
function detectElementType(tag) {
const tagLower = tag.toLowerCase();
// 🔥 FIX: Vérifier d'abord les préfixes de type spécifique (Intro_, Titre_, Txt_)
// avant les suffixes génériques (_title, _text)
// Intro_H2_1, Intro_H3_5, etc. → type 'intro'
if (tagLower.startsWith('intro_')) {
return 'intro';
}
// Titre_H2_1, Titre_H3_5, etc. → type 'titre'
if (tagLower.startsWith('titre_')) {
return 'titre';
}
// Txt_H2_1, Txt_H3_5, etc. → type 'paragraphe'
if (tagLower.startsWith('txt_')) {
return 'paragraphe';
}
// Conclusion_* → type 'conclusion'
if (tagLower.startsWith('conclu') || tagLower.includes('c_1') || tagLower === 'c1') {
return 'conclusion';
}
// FAQ
if (tagLower.includes('faq') || tagLower.includes('question') || tagLower.startsWith('q_') || tagLower.startsWith('q-')) {
return 'faq_question';
}
if (tagLower.includes('answer') || tagLower.includes('réponse') || tagLower.includes('reponse') || tagLower.startsWith('a_') || tagLower.startsWith('a-')) {
return 'faq_reponse';
}
// Suffixes génériques pour format alternatif (H2_1_title, H2_1_text)
// À vérifier APRÈS les préfixes pour éviter les conflits
if (tagLower.endsWith('_title')) {
return 'titre';
} else if (tagLower.endsWith('_text')) {
return 'paragraphe';
}
// Paragraphes (défaut)
return 'paragraphe';
}
/**
* Détecter contrainte de longueur dans une instruction
* Retourne { hasConstraint: boolean, constraint: string|null }
*/
function detectLengthConstraintInInstruction(instruction) {
if (!instruction) return { hasConstraint: false, constraint: null };
const lowerInstr = instruction.toLowerCase();
// Patterns de contraintes : "X mots", "X-Y mots", "environ X mots", "maximum X mots"
const patterns = [
/(\d+)\s*-\s*(\d+)\s*mots?/i, // "80-200 mots"
/environ\s+(\d+)\s*mots?/i, // "environ 100 mots"
/maximum\s+(\d+)\s*mots?/i, // "maximum 25 mots"
/minimum\s+(\d+)\s*mots?/i, // "minimum 50 mots"
/(\d+)\s+mots?\s+(maximum|minimum)/i, // "25 mots maximum"
/^(\d+)\s*mots?$/i, // "25 mots" seul
/\b(\d+)\s*mots?\b/i // "X mots" quelque part
];
for (const pattern of patterns) {
const match = instruction.match(pattern);
if (match) {
return { hasConstraint: true, constraint: match[0] };
}
}
return { hasConstraint: false, constraint: null };
}
/**
* Créer un prompt adapté au type d'élément avec contraintes de longueur (legacy logic)
* @param {string} associatedTitle - Titre généré précédemment pour les textes/intros (important pour cohérence)
*/
function createTypedPrompt(tag, type, instruction, csvData, associatedTitle = null) {
const keyword = csvData.mc0 || '';
const title = csvData.t0 || '';
const personality = csvData.personality;
// 🔥 NOUVEAU : Détecter si l'instruction contient déjà une contrainte de longueur
const instructionConstraint = detectLengthConstraintInInstruction(instruction);
// 📊 LOG: Afficher détection contrainte
if (instructionConstraint.hasConstraint) {
logSh(` 🔍 Contrainte détectée dans instruction: "${instructionConstraint.constraint}"`, 'DEBUG');
} else {
logSh(` ⚙️ Aucune contrainte détectée, utilisation contrainte type "${type}"`, 'DEBUG');
}
let lengthConstraint = '';
let specificInstructions = '';
// Si l'instruction a déjà une contrainte, ne pas en ajouter une autre
if (instructionConstraint.hasConstraint) {
lengthConstraint = `RESPECTE STRICTEMENT la contrainte de longueur indiquée dans l'instruction : "${instructionConstraint.constraint}"`;
// Instructions génériques selon type (sans répéter la longueur)
switch (type) {
case 'titre':
specificInstructions = `Le titre doit être:
- COURT et PERCUTANT
- Pas de phrases complètes
- Intégrer "${keyword}"`;
break;
case 'intro':
specificInstructions = `L'introduction doit:
- Présenter le sujet
- Accrocher le lecteur`;
break;
case 'conclusion':
specificInstructions = `La conclusion doit:
- Résumer les points clés
- Appel à l'action si pertinent`;
break;
case 'faq_question':
specificInstructions = `La question FAQ doit être:
- Courte et directe
- Formulée du point de vue utilisateur`;
break;
case 'faq_reponse':
specificInstructions = `La réponse FAQ doit être:
- Directe et informative
- Répondre précisément à la question`;
break;
case 'paragraphe':
default:
specificInstructions = `Le paragraphe doit:
- Développer un aspect du sujet
- Contenu informatif et engageant`;
break;
}
} else {
// Pas de contrainte dans l'instruction → utiliser les contraintes par défaut du type
switch (type) {
case 'titre':
lengthConstraint = '8-15 mots MAXIMUM';
specificInstructions = `Le titre doit être:
- COURT et PERCUTANT (8-15 mots max)
- Pas de phrases complètes
- Intégrer "${keyword}"`;
break;
case 'intro':
lengthConstraint = '40-80 mots (2-3 phrases courtes)';
specificInstructions = `L'introduction doit:
- Présenter le sujet
- Accrocher le lecteur
- 40-80 mots seulement`;
break;
case 'conclusion':
lengthConstraint = '40-80 mots (2-3 phrases courtes)';
specificInstructions = `La conclusion doit:
- Résumer les points clés
- Appel à l'action si pertinent
- 40-80 mots seulement`;
break;
case 'faq_question':
lengthConstraint = '10-20 mots';
specificInstructions = `La question FAQ doit être:
- Courte et directe (10-20 mots)
- Formulée du point de vue utilisateur`;
break;
case 'faq_reponse':
lengthConstraint = '60-120 mots (3-5 phrases)';
specificInstructions = `La réponse FAQ doit être:
- Directe et informative (60-120 mots)
- Répondre précisément à la question`;
break;
case 'paragraphe':
default:
lengthConstraint = '80-200 mots (3-6 phrases)';
specificInstructions = `Le paragraphe doit:
- Développer un aspect du sujet
- Contenu informatif et engageant
- 80-200 mots (PAS PLUS)`;
break;
}
}
// 🔥 NOUVEAU : Injecter le titre associé pour textes/intros
let titleContext = '';
if (associatedTitle && (type === 'intro' || type === 'paragraphe')) {
titleContext = `\n🎯 TITRE ASSOCIÉ (IMPORTANT - utilise-le comme base): "${associatedTitle}"\n⚠️ CRUCIAL: Le contenu doit développer et être cohérent avec ce titre spécifique.\n`;
}
// 🔥 Helper : Sélectionner aléatoirement max N éléments d'un array
const selectRandomItems = (arr, max = 2) => {
if (!Array.isArray(arr) || arr.length === 0) return arr;
if (arr.length <= max) return arr;
// Fisher-Yates shuffle puis prendre les N premiers
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled.slice(0, max);
};
// 🔥 NOUVEAU : Contexte personnalité enrichi
// ⚠️ EXCLUSION : Pas de personnalité pour les questions FAQ (seulement pour les réponses)
let personalityContext = '';
const includePersonality = personality && type !== 'faq_question';
if (includePersonality) {
// 🎲 Sélection aléatoire de 2 éléments max pour D, E, F, J
const vocabArray = Array.isArray(personality.vocabulairePref)
? personality.vocabulairePref
: (personality.vocabulairePref || '').split(',').map(s => s.trim()).filter(s => s);
const vocabList = selectRandomItems(vocabArray, 2).join(', ');
const connecteursArray = Array.isArray(personality.connecteursPref)
? personality.connecteursPref
: (personality.connecteursPref || '').split(',').map(s => s.trim()).filter(s => s);
const connecteursList = selectRandomItems(connecteursArray, 2).join(', ');
const motsClésArray = Array.isArray(personality.motsClesSecteurs)
? personality.motsClesSecteurs
: (personality.motsClesSecteurs || '').split(',').map(s => s.trim()).filter(s => s);
const motsClesList = selectRandomItems(motsClésArray, 2).join(', ');
const ctaArray = Array.isArray(personality.ctaStyle)
? personality.ctaStyle
: (personality.ctaStyle || '').split(',').map(s => s.trim()).filter(s => s);
const ctaList = selectRandomItems(ctaArray, 2).join(', ');
personalityContext = `
PROFIL PERSONNALITÉ RÉDACTEUR:
- Nom: ${personality.nom || 'Standard'}
- Profil: ${personality.description || 'Expert généraliste'}
- Style: ${personality.style || 'professionnel'}
${motsClesList ? `- Secteurs d'expertise: ${motsClesList}` : ''}
${vocabList ? `- Vocabulaire préféré: ${vocabList}` : ''}
${connecteursList ? `- Connecteurs préférés: ${connecteursList}` : ''}
${personality.longueurPhrases ? `- Longueur phrases: ${personality.longueurPhrases}` : ''}
${personality.niveauTechnique ? `- Niveau technique: ${personality.niveauTechnique}` : ''}
${ctaList ? `- Style CTA: ${ctaList}` : ''}
`;
}
const prompt = `Tu es un rédacteur SEO expert. Génère du contenu professionnel et naturel.
CONTEXTE:
- Sujet principal: ${keyword}
- Titre de l'article: ${title}
${personalityContext}${titleContext}
ÉLÉMENT À GÉNÉRER: ${tag} (Type: ${type})
INSTRUCTION SPÉCIFIQUE:
${instruction}
CONTRAINTE DE LONGUEUR (⚠️ CRUCIAL - À RESPECTER ABSOLUMENT):
${lengthConstraint}
${specificInstructions}
CONSIGNES RÉDACTIONNELLES:
${includePersonality ? `- ADOPTE le style et vocabulaire du profil personnalité ci-dessus
- Utilise les connecteurs préférés listés pour fluidifier le texte
- Adapte la longueur des phrases selon le profil (${personality?.longueurPhrases || 'moyennes'})
- Niveau technique: ${personality?.niveauTechnique || 'moyen'}` : '- Formulation neutre et professionnelle (question FAQ)'}
- Ton naturel et humain, pas robotique
- Intégration fluide du mot-clé "${keyword}"
${associatedTitle ? `- DÉVELOPPE spécifiquement le titre: "${associatedTitle}"` : ''}
- PAS de formatage markdown (ni **, ni ##, ni -)
- PAS de préambule ou conclusion ajoutée
- ⚠️ IMPÉRATIF: RESPECTE la contrainte de longueur indiquée ci-dessus
RÉPONSE (contenu uniquement, sans intro comme "Voici le contenu..."):`;
return prompt;
}
/**
* Génération simple avec LLM configurable (compatible avec l'ancien système)
*/
async function generateSimple(hierarchy, csvData, options = {}) {
const LLMManager = require('../LLMManager');
const llmProvider = options.llmProvider || 'claude-sonnet-4-5';
logSh(`🔥 Génération avec contraintes de longueur par type (${llmProvider.toUpperCase()})`, 'INFO');
if (!hierarchy || Object.keys(hierarchy).length === 0) {
throw new Error('Hiérarchie vide ou invalide');
}
const result = {
content: {},
stats: {
processed: 0,
enhanced: 0,
duration: 0,
llmProvider: llmProvider
}
};
const startTime = Date.now();
// Fonction utilitaire pour résoudre les variables
const resolveVariables = (text, csvData) => {
return text.replace(/\{\{?([^}]+)\}?\}/g, (match, variable) => {
const cleanVar = variable.trim();
if (cleanVar === 'MC0') return csvData.mc0 || '';
if (cleanVar === 'T0') return csvData.t0 || '';
if (cleanVar === 'T-1') return csvData.tMinus1 || '';
if (cleanVar === 'L-1') return csvData.lMinus1 || '';
if (cleanVar.startsWith('MC+1_')) {
const index = parseInt(cleanVar.split('_')[1]) - 1;
const mcPlus1 = (csvData.mcPlus1 || '').split(',').map(s => s.trim());
return mcPlus1[index] || csvData.mc0 || '';
}
if (cleanVar.startsWith('T+1_')) {
const index = parseInt(cleanVar.split('_')[1]) - 1;
const tPlus1 = (csvData.tPlus1 || '').split(',').map(s => s.trim());
return tPlus1[index] || csvData.t0 || '';
}
if (cleanVar.startsWith('L+1_')) {
const index = parseInt(cleanVar.split('_')[1]) - 1;
const lPlus1 = (csvData.lPlus1 || '').split(',').map(s => s.trim());
return lPlus1[index] || '';
}
return csvData.mc0 || '';
});
};
// Fonction pour extraire l'instruction de l'élément
const extractInstruction = (tag, item) => {
if (typeof item === 'string') return item;
if (item.instructions) return item.instructions;
if (item.title && item.title.instructions) return item.title.instructions;
if (item.text && item.text.instructions) return item.text.instructions;
if (item.questions && Array.isArray(item.questions) && item.questions.length > 0) {
const faqItem = item.questions[0];
if (faqItem.originalElement && faqItem.originalElement.resolvedContent) {
return faqItem.originalElement.resolvedContent;
}
return `Générer une ${tag.startsWith('q') ? 'question' : 'réponse'} FAQ pertinente sur ${csvData.mc0}`;
}
return `Générer du contenu pertinent pour la section ${tag} sur "${csvData.mc0}"`;
};
try {
// Grouper éléments par couples (titre/texte et FAQ)
const batches = [];
for (const [sectionKey, section] of Object.entries(hierarchy)) {
const batch = [];
// Couple titre + texte
if (section.title && section.text) {
// 🔥 FIX: Utiliser le nom original pour préserver le type (Intro_, Txt_, etc.)
const titleTag = section.title.originalElement?.name || `${sectionKey}_title`;
const textTag = section.text.originalElement?.name || `${sectionKey}_text`;
batch.push({ tag: titleTag, item: section.title, isCouple: 'titre' });
batch.push({ tag: textTag, item: section.text, isCouple: 'texte' });
} else if (section.title) {
const tag = section.title.originalElement?.name || sectionKey;
batch.push({ tag: tag, item: section.title, isCouple: null });
} else if (section.text) {
const tag = section.text.originalElement?.name || sectionKey;
batch.push({ tag: tag, item: section.text, isCouple: null });
}
// Paires FAQ (q_1 + a_1, q_2 + a_2, etc.)
if (section.questions && section.questions.length > 0) {
for (let i = 0; i < section.questions.length; i += 2) {
const question = section.questions[i];
const answer = section.questions[i + 1];
if (question) {
batch.push({
tag: question.hierarchyPath || `faq_q_${i}`,
item: question,
isCouple: 'faq_question'
});
}
if (answer) {
batch.push({
tag: answer.hierarchyPath || `faq_a_${i}`,
item: answer,
isCouple: 'faq_reponse'
});
}
}
}
if (batch.length > 0) {
batches.push(...batch);
}
}
logSh(`📊 Total éléments à générer: ${batches.length}`, 'INFO');
// 🔥 NOUVEAU : Tracker le dernier titre généré pour l'associer au texte suivant
let lastGeneratedTitle = null;
// Générer chaque élément avec prompt typé
for (let i = 0; i < batches.length; i++) {
const { tag, item, isCouple } = batches[i];
try {
logSh(`🎯 Génération: ${tag}${isCouple ? ` (couple: ${isCouple})` : ''}`, 'DEBUG');
// 🔥 NOUVEAU : Détecter si le prochain élément est un texte associé à un titre
const isTitle = isCouple === 'titre';
const nextBatch = i < batches.length - 1 ? batches[i + 1] : null;
const nextIsText = nextBatch && (nextBatch.isCouple === 'texte');
if (isTitle && nextIsText) {
logSh(` 🔗 Détecté couple titre→texte : ${tag}${nextBatch.tag}`, 'DEBUG');
}
// Extraire et résoudre l'instruction
let instruction = extractInstruction(tag, item);
instruction = instruction.trim();
instruction = resolveVariables(instruction, csvData);
// Résoudre variables non résolues manuellement
const unresolvedPattern = /\b(MC\+1_\d+|T\+1_\d+|L\+1_\d+|MC0|T0|T-1|L-1)\b/gi;
const unresolved = instruction.match(unresolvedPattern);
if (unresolved) {
unresolved.forEach(varName => {
const upperVar = varName.toUpperCase();
let replacement = csvData.mc0 || '';
if (upperVar === 'MC0') replacement = csvData.mc0 || '';
else if (upperVar === 'T0') replacement = csvData.t0 || '';
else if (upperVar === 'T-1') replacement = csvData.tMinus1 || '';
else if (upperVar === 'L-1') replacement = csvData.lMinus1 || '';
else if (upperVar.startsWith('MC+1_')) {
const idx = parseInt(upperVar.split('_')[1]) - 1;
replacement = (csvData.mcPlus1 || '').split(',')[idx]?.trim() || csvData.mc0 || '';
} else if (upperVar.startsWith('T+1_')) {
const idx = parseInt(upperVar.split('_')[1]) - 1;
replacement = (csvData.tPlus1 || '').split(',')[idx]?.trim() || csvData.t0 || '';
} else if (upperVar.startsWith('L+1_')) {
const idx = parseInt(upperVar.split('_')[1]) - 1;
replacement = (csvData.lPlus1 || '').split(',')[idx]?.trim() || '';
}
instruction = instruction.replace(new RegExp(varName, 'gi'), replacement);
});
}
// Nettoyer accolades mal formées
instruction = instruction.replace(/\{[^}]*/g, '').replace(/[{}]/g, '').trim();
if (!instruction || instruction.length < 10) {
logSh(` ⚠️ ${tag}: Instruction trop courte (${instruction?.length || 0} chars), utilisation fallback`, 'WARNING');
instruction = `Générer du contenu pertinent pour ${tag} sur "${csvData.mc0}"`;
}
// Détecter le type d'élément
const elementType = detectElementType(tag);
logSh(` 📝 Type détecté: ${elementType}`, 'DEBUG');
// 🔥 NOUVEAU : Si c'est un texte et qu'on a un titre généré juste avant, l'utiliser
const shouldUseTitle = (isCouple === 'texte') && lastGeneratedTitle;
if (shouldUseTitle) {
logSh(` 🎯 Utilisation du titre associé: "${lastGeneratedTitle}"`, 'INFO');
}
// Créer le prompt avec contraintes de longueur + titre associé si disponible
const prompt = createTypedPrompt(tag, elementType, instruction, csvData, shouldUseTitle ? lastGeneratedTitle : null);
// Appeler le LLM avec maxTokens augmenté
let maxTokens = 1000; // Défaut augmenté
if (llmProvider.startsWith('gpt-5')) {
maxTokens = 2500; // GPT-5 avec reasoning tokens
} else if (llmProvider.startsWith('gpt-4')) {
maxTokens = 1500;
} else if (llmProvider.startsWith('claude')) {
maxTokens = 2000;
}
logSh(` 📏 MaxTokens: ${maxTokens} pour ${llmProvider}`, 'DEBUG');
let response;
try {
response = await LLMManager.callLLM(llmProvider, prompt, {
temperature: 0.9,
maxTokens: maxTokens,
timeout: 45000
}, csvData.personality);
} catch (llmError) {
logSh(`❌ Erreur LLM pour ${tag}: ${llmError.message}`, 'ERROR');
response = null;
}
if (response && response.trim()) {
const cleaned = cleanGeneratedContent(response.trim());
result.content[tag] = cleaned;
result.stats.processed++;
result.stats.enhanced++;
// 🔥 NOUVEAU : Si c'est un titre, le stocker pour l'utiliser avec le texte suivant
if (isTitle) {
lastGeneratedTitle = cleaned;
logSh(` 📌 Titre stocké pour le texte suivant: "${cleaned}"`, 'DEBUG');
}
// 🔥 NOUVEAU : Si on vient de générer un texte, réinitialiser le titre
if (isCouple === 'texte') {
lastGeneratedTitle = null;
}
const wordCount = cleaned.split(/\s+/).length;
logSh(` ✅ Généré: ${tag} (${wordCount} mots)`, 'DEBUG');
} else {
// Fallback avec prompt simplifié
logSh(` ⚠️ Réponse vide, retry avec gpt-4o-mini`, 'WARNING');
try {
const simplePrompt = `Rédige du contenu professionnel sur "${csvData.mc0}" pour ${tag}. ${elementType === 'titre' ? 'Maximum 15 mots.' : elementType === 'intro' || elementType === 'conclusion' ? 'Environ 50-80 mots.' : 'Environ 100-150 mots.'}`;
const retryResponse = await LLMManager.callLLM('gpt-4o-mini', simplePrompt, {
temperature: 0.7,
maxTokens: 500,
timeout: 20000
});
if (retryResponse && retryResponse.trim()) {
const cleaned = cleanGeneratedContent(retryResponse.trim());
result.content[tag] = cleaned;
result.stats.processed++;
result.stats.enhanced++;
// 🔥 NOUVEAU : Stocker le titre même dans le fallback
if (isTitle) {
lastGeneratedTitle = cleaned;
logSh(` 📌 Titre stocké (fallback): "${cleaned}"`, 'DEBUG');
}
if (isCouple === 'texte') {
lastGeneratedTitle = null;
}
logSh(` ✅ Retry réussi pour ${tag}`, 'INFO');
} else {
result.content[tag] = `Contenu professionnel sur ${csvData.mc0}. [Généré automatiquement]`;
result.stats.processed++;
// 🔥 NOUVEAU : Réinitialiser si c'était un texte
if (isCouple === 'texte') {
lastGeneratedTitle = null;
}
}
} catch (retryError) {
result.content[tag] = `Contenu professionnel sur ${csvData.mc0}. [Erreur: ${retryError.message.substring(0, 50)}]`;
result.stats.processed++;
// 🔥 NOUVEAU : Réinitialiser si c'était un texte
if (isCouple === 'texte') {
lastGeneratedTitle = null;
}
logSh(` ❌ Retry échoué: ${retryError.message}`, 'ERROR');
}
}
} catch (error) {
logSh(`❌ Erreur génération ${tag}: ${error.message}`, 'ERROR');
result.content[tag] = `Contenu professionnel sur ${csvData.mc0}. [Erreur: ${error.message.substring(0, 50)}]`;
result.stats.processed++;
// 🔥 NOUVEAU : Réinitialiser si c'était un texte
if (isCouple === 'texte') {
lastGeneratedTitle = null;
}
}
}
result.stats.duration = Date.now() - startTime;
const generatedElements = Object.keys(result.content).length;
const successfulElements = result.stats.enhanced;
const fallbackElements = generatedElements - successfulElements;
logSh(`✅ Génération terminée: ${generatedElements} éléments`, 'INFO');
logSh(` ✓ Succès: ${successfulElements}, Fallback: ${fallbackElements}, Durée: ${result.stats.duration}ms`, 'INFO');
return result;
} catch (error) {
result.stats.duration = Date.now() - startTime;
logSh(`❌ Échec génération simple: ${error.message}`, 'ERROR');
throw error;
}
}
/**
* STATISTIQUES ET RAPPORTS
*/
/**
* Générer rapport amélioration
*/
function generateImprovementReport(originalContent, enhancedContent, layerType = 'general') {
const report = {
layerType,
timestamp: new Date().toISOString(),
summary: {
elementsProcessed: 0,
elementsImproved: 0,
averageImprovement: 0,
totalExecutionTime: 0
},
details: {
byElement: [],
qualityMetrics: {},
recommendations: []
}
};
// Analyser chaque élément
Object.keys(originalContent).forEach(tag => {
const original = originalContent[tag];
const enhanced = enhancedContent[tag];
if (original && enhanced) {
report.summary.elementsProcessed++;
const improvement = compareContentImprovement(original, enhanced, layerType);
if (improvement.improvementRate > 0) {
report.summary.elementsImproved++;
}
report.summary.averageImprovement += improvement.improvementRate;
report.details.byElement.push({
tag,
improvementRate: improvement.improvementRate,
lengthChange: improvement.details.lengthChange,
contentPreserved: improvement.details.contentPreserved
});
}
});
// Calculer moyennes
if (report.summary.elementsProcessed > 0) {
report.summary.averageImprovement = report.summary.averageImprovement / report.summary.elementsProcessed;
}
// Métriques qualité globales
const fullOriginal = Object.values(originalContent).join(' ');
const fullEnhanced = Object.values(enhancedContent).join(' ');
report.details.qualityMetrics = {
technical: analyzeTechnicalQuality(fullEnhanced),
transitions: analyzeTransitionFluidity(fullEnhanced),
style: analyzeStyleConsistency(fullEnhanced)
};
// Recommandations
if (report.summary.averageImprovement < 10) {
report.details.recommendations.push('Augmenter l\'intensité d\'amélioration');
}
if (report.details.byElement.some(e => !e.contentPreserved)) {
report.details.recommendations.push('Améliorer préservation du contenu original');
}
return report;
}
module.exports = {
// Analyseurs
analyzeTechnicalQuality,
analyzeTransitionFluidity,
analyzeStyleConsistency,
// Comparateurs
compareContentImprovement,
// Utilitaires contenu
cleanGeneratedContent,
validateSelectiveContent,
// Utilitaires techniques
chunkArray,
sleep,
measurePerformance,
formatDuration,
// Génération simple (remplace ContentGeneration.js)
generateSimple,
// Rapports
generateImprovementReport
};