// ======================================== // 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'); /** * UTILITAIRE: Afficher liste complète des éléments (réutilisable) */ function logElementsList(elements, title = 'LISTE DES ÉLÉMENTS', generatedKeywords = null) { logSh(`\n📋 === ${title} (${elements.length} éléments) ===`, 'INFO'); elements.forEach((el, idx) => { // Déterminer la source si generatedKeywords fourni let source = '📋 GSheet'; if (generatedKeywords) { const isGenerated = generatedKeywords[el.name] || (generatedKeywords._subVariables && Object.keys(generatedKeywords._subVariables).some(k => k.startsWith(el.name + '_'))); source = isGenerated ? '🤖 IA' : '📋 GSheet'; } logSh(` [${idx + 1}] ${source} ${el.name}`, 'INFO'); logSh(` 📄 resolvedContent: "${el.resolvedContent}"`, 'INFO'); if (el.instructions) { const instPreview = el.instructions.length > 100 ? el.instructions.substring(0, 100) + '...' : el.instructions; logSh(` 📜 instructions: "${instPreview}"`, 'INFO'); } }); logSh(`=========================================\n`, 'INFO'); } /** * 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) * @param {string} specificKeyword - Mot-clé spécifique de l'élément (resolvedContent) au lieu du mot-clé général */ function createTypedPrompt(tag, type, instruction, csvData, associatedTitle = null, specificKeyword = null) { // 🔥 FIX: Utiliser UNIQUEMENT le mot-clé spécifique de l'élément (resolvedContent) // PAS de fallback sur csvData.mc0 let keyword; if (specificKeyword) { keyword = specificKeyword; logSh(` 🎯 Mot-clé SPÉCIFIQUE utilisé: "${specificKeyword}"`, 'INFO'); } else { logSh(` ❌ ERREUR: Aucun mot-clé spécifique (resolvedContent) fourni pour ${tag}`, 'ERROR'); keyword = ''; // Pas de fallback } 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; } } // 🔥 LASER FOCUS sur le titre et extraction des mots-clés importants let titleContext = ''; if (associatedTitle && (type === 'intro' || type === 'paragraphe')) { // Extraire les mots-clés importants (> 4 lettres, sauf mots vides courants) const stopWords = ['dans', 'avec', 'pour', 'sans', 'sous', 'vers', 'chez', 'sur', 'par', 'tous', 'toutes', 'cette', 'votre', 'notre']; const titleWords = associatedTitle .toLowerCase() .replace(/[.,;:!?'"]/g, '') .split(/\s+/) .filter(word => word.length > 4 && !stopWords.includes(word)); const keywordsHighlight = titleWords.length > 0 ? `Mots-clés à développer: ${titleWords.join(', ')}\n` : ''; titleContext = ` 🎯 TITRE À DÉVELOPPER: "${associatedTitle}" ${keywordsHighlight}⚠️ IMPORTANT: Ton contenu doit développer SPÉCIFIQUEMENT ce titre et ses concepts clés. Ne génère pas de contenu générique, concentre-toi sur les mots-clés identifiés. `; } // 🔥 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 ? `- 🎯 FOCUS: Développe SPÉCIFIQUEMENT les concepts du titre "${associatedTitle}" (pas de contenu générique)` : ''} - 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'); } // 📊 AFFICHER LA HIÉRARCHIE AVANT GÉNÉRATION const allElementsFromHierarchy = []; for (const [sectionKey, section] of Object.entries(hierarchy)) { if (section.title && section.title.originalElement) { allElementsFromHierarchy.push(section.title.originalElement); } if (section.text && section.text.originalElement) { allElementsFromHierarchy.push(section.text.originalElement); } if (section.questions && section.questions.length > 0) { section.questions.forEach(faq => { if (faq.originalElement) { allElementsFromHierarchy.push(faq.originalElement); } }); } } logElementsList(allElementsFromHierarchy, 'ÉLÉMENTS AVANT GÉNÉRATION (depuis hiérarchie)'); 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) => { let extracted = null; if (typeof item === 'string') { extracted = item; logSh(` 🔍 [${tag}] Instruction: "${extracted}"`, 'INFO'); return extracted; } if (item.instructions) { extracted = item.instructions; logSh(` 🔍 [${tag}] Instruction (item.instructions): "${extracted}"`, 'INFO'); return extracted; } if (item.title && item.title.instructions) { extracted = item.title.instructions; logSh(` 🔍 [${tag}] Instruction (title.instructions): "${extracted}"`, 'INFO'); return extracted; } if (item.text && item.text.instructions) { extracted = item.text.instructions; logSh(` 🔍 [${tag}] Instruction (text.instructions): "${extracted}"`, 'INFO'); return extracted; } if (item.questions && Array.isArray(item.questions) && item.questions.length > 0) { const faqItem = item.questions[0]; if (faqItem.originalElement && faqItem.originalElement.resolvedContent) { extracted = faqItem.originalElement.resolvedContent; logSh(` 🔍 [${tag}] Instruction (FAQ resolvedContent): "${extracted}"`, 'INFO'); return extracted; } logSh(` ⚠️ [${tag}] Pas d'instruction FAQ - ignoré`, 'WARNING'); return ""; } logSh(` ⚠️ [${tag}] Pas d'instruction trouvée - ignoré`, 'WARNING'); return ""; }; 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 { // 📊 AFFICHER LA LISTE AVANT CHAQUE GÉNÉRATION logSh(`\n🎯 === GÉNÉRATION DE: ${tag} (${i + 1}/${batches.length}) ===`, 'INFO'); logElementsList(allElementsFromHierarchy, `ÉTAT DES ÉLÉMENTS AVANT GÉNÉRATION DE ${tag}`); 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}: Pas d'instruction spécifique - génération sans instruction`, 'WARNING'); instruction = ""; // Générer quand même mais sans instruction spécifique } // 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'); } // 🔥 FIX: Extraire le mot-clé spécifique (resolvedContent) de l'élément let specificKeyword = null; if (item && item.originalElement && item.originalElement.resolvedContent) { specificKeyword = item.originalElement.resolvedContent; logSh(` 📝 Mot-clé spécifique extrait: "${specificKeyword}"`, 'DEBUG'); } // Créer le prompt avec contraintes de longueur + titre associé + mot-clé spécifique const prompt = createTypedPrompt(tag, elementType, instruction, csvData, shouldUseTitle ? lastGeneratedTitle : null, specificKeyword); // 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 = { // Utilitaires logging logElementsList, // 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 };